Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

nsqadmin: base path #856

Merged
merged 2 commits into from
Sep 4, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/nsqadmin/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
var (
flagSet = flag.NewFlagSet("nsqadmin", flag.ExitOnError)

basePath = flagSet.String("base-path", "/", "URL base path")
config = flagSet.String("config", "", "path to config file")
showVersion = flagSet.Bool("version", false, "print version string")

Expand Down
82 changes: 41 additions & 41 deletions nsqadmin/bindata.go

Large diffs are not rendered by default.

82 changes: 46 additions & 36 deletions nsqadmin/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,11 @@ func NewSingleHostReverseProxy(target *url.URL, connectTimeout time.Duration, re
}

type httpServer struct {
ctx *Context
router http.Handler
client *http_api.Client
ci *clusterinfo.ClusterInfo
ctx *Context
router http.Handler
client *http_api.Client
ci *clusterinfo.ClusterInfo
basePath string
}

func NewHTTPServer(ctx *Context) *httpServer {
Expand All @@ -66,47 +67,52 @@ func NewHTTPServer(ctx *Context) *httpServer {
router.NotFound = http_api.LogNotFoundHandler(ctx.nsqadmin.logf)
router.MethodNotAllowed = http_api.LogMethodNotAllowedHandler(ctx.nsqadmin.logf)
s := &httpServer{
ctx: ctx,
router: router,
client: client,
ci: clusterinfo.New(ctx.nsqadmin.logf, client),
ctx: ctx,
router: router,
client: client,
ci: clusterinfo.New(ctx.nsqadmin.logf, client),
basePath: ctx.nsqadmin.getOpts().BasePath,
}

router.Handle("GET", "/ping", http_api.Decorate(s.pingHandler, log, http_api.PlainText))
bp := func(p string) string {
return path.Join(s.basePath, p)
}

router.Handle("GET", bp("/"), http_api.Decorate(s.indexHandler, log))
router.Handle("GET", bp("/ping"), http_api.Decorate(s.pingHandler, log, http_api.PlainText))

router.Handle("GET", "/", http_api.Decorate(s.indexHandler, log))
router.Handle("GET", "/topics", http_api.Decorate(s.indexHandler, log))
router.Handle("GET", "/topics/:topic", http_api.Decorate(s.indexHandler, log))
router.Handle("GET", "/topics/:topic/:channel", http_api.Decorate(s.indexHandler, log))
router.Handle("GET", "/nodes", http_api.Decorate(s.indexHandler, log))
router.Handle("GET", "/nodes/:node", http_api.Decorate(s.indexHandler, log))
router.Handle("GET", "/counter", http_api.Decorate(s.indexHandler, log))
router.Handle("GET", "/lookup", http_api.Decorate(s.indexHandler, log))
router.Handle("GET", bp("/topics"), http_api.Decorate(s.indexHandler, log))
router.Handle("GET", bp("/topics/:topic"), http_api.Decorate(s.indexHandler, log))
router.Handle("GET", bp("/topics/:topic/:channel"), http_api.Decorate(s.indexHandler, log))
router.Handle("GET", bp("/nodes"), http_api.Decorate(s.indexHandler, log))
router.Handle("GET", bp("/nodes/:node"), http_api.Decorate(s.indexHandler, log))
router.Handle("GET", bp("/counter"), http_api.Decorate(s.indexHandler, log))
router.Handle("GET", bp("/lookup"), http_api.Decorate(s.indexHandler, log))

router.Handle("GET", "/static/:asset", http_api.Decorate(s.staticAssetHandler, log, http_api.PlainText))
router.Handle("GET", "/fonts/:asset", http_api.Decorate(s.staticAssetHandler, log, http_api.PlainText))
router.Handle("GET", bp("/static/:asset"), http_api.Decorate(s.staticAssetHandler, log, http_api.PlainText))
router.Handle("GET", bp("/fonts/:asset"), http_api.Decorate(s.staticAssetHandler, log, http_api.PlainText))
if s.ctx.nsqadmin.getOpts().ProxyGraphite {
proxy := NewSingleHostReverseProxy(ctx.nsqadmin.graphiteURL, ctx.nsqadmin.getOpts().HTTPClientConnectTimeout,
ctx.nsqadmin.getOpts().HTTPClientRequestTimeout)
router.Handler("GET", "/render", proxy)
router.Handler("GET", bp("/render"), proxy)
}

// v1 endpoints
router.Handle("GET", "/api/topics", http_api.Decorate(s.topicsHandler, log, http_api.V1))
router.Handle("GET", "/api/topics/:topic", http_api.Decorate(s.topicHandler, log, http_api.V1))
router.Handle("GET", "/api/topics/:topic/:channel", http_api.Decorate(s.channelHandler, log, http_api.V1))
router.Handle("GET", "/api/nodes", http_api.Decorate(s.nodesHandler, log, http_api.V1))
router.Handle("GET", "/api/nodes/:node", http_api.Decorate(s.nodeHandler, log, http_api.V1))
router.Handle("POST", "/api/topics", http_api.Decorate(s.createTopicChannelHandler, log, http_api.V1))
router.Handle("POST", "/api/topics/:topic", http_api.Decorate(s.topicActionHandler, log, http_api.V1))
router.Handle("POST", "/api/topics/:topic/:channel", http_api.Decorate(s.channelActionHandler, log, http_api.V1))
router.Handle("DELETE", "/api/nodes/:node", http_api.Decorate(s.tombstoneNodeForTopicHandler, log, http_api.V1))
router.Handle("DELETE", "/api/topics/:topic", http_api.Decorate(s.deleteTopicHandler, log, http_api.V1))
router.Handle("DELETE", "/api/topics/:topic/:channel", http_api.Decorate(s.deleteChannelHandler, log, http_api.V1))
router.Handle("GET", "/api/counter", http_api.Decorate(s.counterHandler, log, http_api.V1))
router.Handle("GET", "/api/graphite", http_api.Decorate(s.graphiteHandler, log, http_api.V1))
router.Handle("GET", "/config/:opt", http_api.Decorate(s.doConfig, log, http_api.V1))
router.Handle("PUT", "/config/:opt", http_api.Decorate(s.doConfig, log, http_api.V1))
router.Handle("GET", bp("/api/topics"), http_api.Decorate(s.topicsHandler, log, http_api.V1))
router.Handle("GET", bp("/api/topics/:topic"), http_api.Decorate(s.topicHandler, log, http_api.V1))
router.Handle("GET", bp("/api/topics/:topic/:channel"), http_api.Decorate(s.channelHandler, log, http_api.V1))
router.Handle("GET", bp("/api/nodes"), http_api.Decorate(s.nodesHandler, log, http_api.V1))
router.Handle("GET", bp("/api/nodes/:node"), http_api.Decorate(s.nodeHandler, log, http_api.V1))
router.Handle("POST", bp("/api/topics"), http_api.Decorate(s.createTopicChannelHandler, log, http_api.V1))
router.Handle("POST", bp("/api/topics/:topic"), http_api.Decorate(s.topicActionHandler, log, http_api.V1))
router.Handle("POST", bp("/api/topics/:topic/:channel"), http_api.Decorate(s.channelActionHandler, log, http_api.V1))
router.Handle("DELETE", bp("/api/nodes/:node"), http_api.Decorate(s.tombstoneNodeForTopicHandler, log, http_api.V1))
router.Handle("DELETE", bp("/api/topics/:topic"), http_api.Decorate(s.deleteTopicHandler, log, http_api.V1))
router.Handle("DELETE", bp("/api/topics/:topic/:channel"), http_api.Decorate(s.deleteChannelHandler, log, http_api.V1))
router.Handle("GET", bp("/api/counter"), http_api.Decorate(s.counterHandler, log, http_api.V1))
router.Handle("GET", bp("/api/graphite"), http_api.Decorate(s.graphiteHandler, log, http_api.V1))
router.Handle("GET", bp("/config/:opt"), http_api.Decorate(s.doConfig, log, http_api.V1))
router.Handle("PUT", bp("/config/:opt"), http_api.Decorate(s.doConfig, log, http_api.V1))

return s
}
Expand All @@ -121,7 +127,11 @@ func (s *httpServer) pingHandler(w http.ResponseWriter, req *http.Request, ps ht

func (s *httpServer) indexHandler(w http.ResponseWriter, req *http.Request, ps httprouter.Params) (interface{}, error) {
asset, _ := Asset("index.html")
t, _ := template.New("index").Parse(string(asset))
t, _ := template.New("index").Funcs(template.FuncMap{
"basePath": func(p string) string {
return path.Join(s.basePath, p)
},
}).Parse(string(asset))

w.Header().Set("Content-Type", "text/html")
t.Execute(w, struct {
Expand Down
14 changes: 14 additions & 0 deletions nsqadmin/nsqadmin.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"net/http"
"net/url"
"os"
"path"
"sync"
"sync/atomic"

Expand Down Expand Up @@ -130,11 +131,24 @@ func New(opts *Options) *NSQAdmin {
}
}

opts.BasePath = normalizeBasePath(opts.BasePath)

n.logf(LOG_INFO, version.String("nsqadmin"))

return n
}

func normalizeBasePath(p string) string {
if len(p) == 0 {
return "/"
}
// add leading slash
if p[0] != '/' {
p = "/" + p
}
return path.Clean(p)
}

func (n *NSQAdmin) getOpts() *Options {
return n.opts.Load().(*Options)
}
Expand Down
2 changes: 2 additions & 0 deletions nsqadmin/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ type Options struct {
logLevel lg.LogLevel // private, not really an option

HTTPAddress string `flag:"http-address"`
BasePath string `flag:"base-path"`

GraphiteURL string `flag:"graphite-url"`
ProxyGraphite bool `flag:"proxy-graphite"`
Expand Down Expand Up @@ -48,6 +49,7 @@ func NewOptions() *Options {
LogPrefix: "[nsqadmin] ",
LogLevel: "info",
HTTPAddress: "0.0.0.0:4171",
BasePath: "/",
StatsdPrefix: "nsq.%s",
StatsdCounterFormat: "stats.counters.%s.count",
StatsdGaugeFormat: "stats.gauges.%s",
Expand Down
25 changes: 13 additions & 12 deletions nsqadmin/static/html/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@
<html>
<head>
<title>nsqadmin</title>
<link rel="icon" type="image/png" href="/static/favicon.png">
<link rel="stylesheet" href="/static/bootstrap.min.css">
<link rel="stylesheet" href="/static/base.css">
<link rel="icon" type="image/png" href="{{basePath "/static/favicon.png"}}">
<link rel="stylesheet" href="{{basePath "/static/bootstrap.min.css"}}">
<link rel="stylesheet" href="{{basePath "/static/base.css"}}">
<meta http-equiv="Content-type" content="text/html; charset=utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
Expand All @@ -15,17 +15,18 @@

<script type="text/javascript">
var USER_AGENT = 'nsqadmin/v{{.Version}}';
var VERSION = '{{.Version}}';
var GRAPHITE_URL = '{{if .ProxyGraphite}}{{else}}{{.GraphiteURL}}{{end}}';
var VERSION = {{.Version}};
var GRAPHITE_URL = {{if .ProxyGraphite}}{{else}}{{.GraphiteURL}}{{end}};
var GRAPH_ENABLED = {{if .GraphEnabled}}true{{else}}false{{end}};
var STATSD_COUNTER_FORMAT = '{{.StatsdCounterFormat}}';
var STATSD_GAUGE_FORMAT = '{{.StatsdGaugeFormat}}';
var STATSD_COUNTER_FORMAT = {{.StatsdCounterFormat}};
var STATSD_GAUGE_FORMAT = {{.StatsdGaugeFormat}};
var STATSD_INTERVAL = {{.StatsdInterval}};
var STATSD_PREFIX = '{{.StatsdPrefix}}';
var NSQLOOKUPD = [{{range .NSQLookupd}}'{{.}}',{{end}}];
var IS_ADMIN ={{.IsAdmin}};
var STATSD_PREFIX = {{.StatsdPrefix}};
var NSQLOOKUPD = [{{range .NSQLookupd}}{{.}},{{end}}];
var IS_ADMIN = {{.IsAdmin}};
var BASE_PATH = {{basePath ""}};
</script>
<script src="/static/vendor.js"></script>
<script src="/static/main.js"></script>
<script src="{{basePath "/static/vendor.js"}}"></script>
<script src="{{basePath "/static/main.js"}}"></script>
</body>
</html>
14 changes: 11 additions & 3 deletions nsqadmin/static/js/app_state.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ var AppState = Backbone.Model.extend({
'STATSD_PREFIX': STATSD_PREFIX,
'NSQLOOKUPD': NSQLOOKUPD,
'graph_interval': '2h',
'IS_ADMIN': IS_ADMIN
'IS_ADMIN': IS_ADMIN,
'BASE_PATH': BASE_PATH
};
},

Expand All @@ -30,8 +31,15 @@ var AppState = Backbone.Model.extend({
this.set('graph_interval', interval);
},

url: function(url) {
return '/api' + url;
basePath: function(p) {
// if base path is / then don't prefix
var bp = this.get('BASE_PATH') == '/' ? '' : this.get('BASE_PATH');
// remove trailing /, but guarantee at least /
return (bp + p).replace(/\/$/, '') || '/';
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this removes the trailing slash from p ... did you intend to remove the trailing slash from bp?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's some unexpected mess and complexity in this approach ... I think it can be simplified to "just remove trailing slash from basepath".

If the base path is "/" then we want ""
If the base path is "/admin/" then we want "/admin"

Then, you can append anything to that, e.g. "/topics", or just "/" to get the root URL path.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess this function is pretty much the only problem here, the rest looks good.

In Go land, path.Join() takes care of everything after the leading /.

So I guess this is the only place where you might have to worry about a double-slash between BASE_PATH and p.

This logic seems a bit much for what should still be a trivial function. I think my initial suggestion here was appropriate, just remove trailing slash from BASE_PATH.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

... e.g.

return this.get('BASE_PATH').replace(/\/$/, '') + p;

but since this is the only place BASE_PATH is used, you could do the regex just once, in index.html:

var BASE_PATH = {{basePath ""}}.replace(/\/$/, '');

and then here it's just

return this.get('BASE_PATH') + p;

Copy link
Member

@mreiferson mreiferson Sep 4, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The case this is addressing is, e.g. where BASE_PATH is /base and in one of our handlebars templates we have {{basePath "/"}}. What we want is /base not /base/, but if BASE_PATH is / we want / not //.

Basically, the answer to your original question is that I intended to remove any potential trailing slash from the joined path.

Also, FWIW, I think BASE_PATH (the global var that's passed into AppState) should always represent the normalized value of whatever was passed to nsqadmin --base-path.

If you can come up with an easier way to do this, I'm all ears.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Heh 😅I'm going to leave this as is, thanks for the feedback.

You just have to accept that the root page path is /base/, and all the conditionals go away.

FWIW, if I had gone in this direction, it would have added a little more cruft to the Backbone routing side to handle that special case.

So the only value of p this could be useful for is p == "/" in which case you could instead handle it like if (p == "/") return this.get('BASE_PATH')

Not just / but anything that ends in a /. I realize we don't actually do this anywhere, but it feels like a -1 for special casing?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why would we choose a path for a new page that ends in /, only for our code to remove that / here?

There still are special cases for / in both variables - but I think a formulation like this is more clear / less tricky:

var bp = this.get('BASE_PATH');
if (bp == '/')
  return p;
if (p == '/')
  return bp;
return bp + p;

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fwiw, I think Backbone can handle trailing slash:

Trailing slashes are treated as part of the URL, and (correctly) treated as a unique route when accessed. docs and docs/ will fire different callbacks.

http://backbonejs.org/#Router-routes

I'm a bit more surprised that it can handle the empty string when the leading slash is removed from this.route(bp('/'), 'topics'); but I think that would happen either way. (It's fun to think through this case for the default base-path: that bp() calls this basePath(), BASE_PATH is "/" so prefix with empty string, remove "/", that results in empty string so return "/" instead, finally bp() removes first slash, resulting in empty string.)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I know that Backbone can support it, I didn't mean to imply otherwise. I just think it's inconsistent with all the other routes that are not currently configured to allow it (although, again, we never link to them without a trailing slash).

This feels like a good baseline so I think we should land this. If you're so inclined, I look forward to your follow up PR where I encourage you to explore this and any other rabbit hole that piques your interest 😉. I am totally willing to land something that demonstrates a simpler overall approach.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, let's merge this

},

apiPath: function(p) {
return this.basePath('/api' + p);
}
});

Expand Down
2 changes: 1 addition & 1 deletion nsqadmin/static/js/collections/nodes.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ var Nodes = Backbone.Collection.extend({
},

url: function() {
return AppState.url('/nodes');
return AppState.apiPath('/nodes');
},

parse: function(resp) {
Expand Down
2 changes: 1 addition & 1 deletion nsqadmin/static/js/collections/topics.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ var Topics = Backbone.Collection.extend({
},

url: function() {
return AppState.url('/topics');
return AppState.apiPath('/topics');
},

parse: function(resp) {
Expand Down
7 changes: 5 additions & 2 deletions nsqadmin/static/js/lib/handlebars_helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ var formatStatsdKey = function(metricType, key) {
}

return fullKey;
}
};

var statsdPrefix = function(host) {
var prefix = AppState.get('STATSD_PREFIX');
Expand All @@ -25,7 +25,6 @@ var statsdPrefix = function(host) {
if (prefix.substring(prefix.length, 1) !== '.') {
prefix += '.';
}

return prefix;
};

Expand Down Expand Up @@ -281,3 +280,7 @@ Handlebars.registerHelper('rate', function(typ, node, ns1, ns2) {

Handlebars.registerPartial('error', require('../views/error.hbs'));
Handlebars.registerPartial('warning', require('../views/warning.hbs'));

Handlebars.registerHelper('basePath', function(p) {
return AppState.basePath(p);
});
2 changes: 1 addition & 1 deletion nsqadmin/static/js/models/channel.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ var Channel = Backbone.Model.extend({
},

url: function() {
return AppState.url('/topics/' +
return AppState.apiPath('/topics/' +
encodeURIComponent(this.get('topic')) + '/' +
encodeURIComponent(this.get('name')));
},
Expand Down
2 changes: 1 addition & 1 deletion nsqadmin/static/js/models/node.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ var Node = Backbone.Model.extend({ //eslint-disable-line no-undef
},

urlRoot: function() {
return AppState.url('/nodes');
return AppState.apiPath('/nodes');
},

tombstoneTopic: function(topic) {
Expand Down
2 changes: 1 addition & 1 deletion nsqadmin/static/js/models/topic.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ var Topic = Backbone.Model.extend({
},

url: function() {
return AppState.url('/topics/' + encodeURIComponent(this.get('name')));
return AppState.apiPath('/topics/' + encodeURIComponent(this.get('name')));
},

parse: function(response) {
Expand Down
28 changes: 13 additions & 15 deletions nsqadmin/static/js/router.js
Original file line number Diff line number Diff line change
@@ -1,25 +1,23 @@
var Backbone = require('backbone');

var AppState = require('./app_state');
var Pubsub = require('./lib/pubsub');


var Router = Backbone.Router.extend({
routes: {
'': 'topics',
'topics/(:topic)(/:channel)': 'topic',
'lookup': 'lookup',
'nodes(/:node)': 'nodes',
'counter': 'counter'
},

defaultRoute: 'topics',

initialize: function() {
this.currentRoute = this.defaultRoute;
this.listenTo(this, 'route', function(route, params) {
this.currentRoute = route || this.defaultRoute;
// console.log('Route: %o; params: %o', route, params);
});
var bp = function(p) {
// remove leading slash
return AppState.basePath(p).substring(1);
};
this.route(bp('/'), 'topics');
this.route(bp('/topics/(:topic)(/:channel)'), 'topic');
this.route(bp('/lookup'), 'lookup');
this.route(bp('/nodes(/:node)'), 'nodes');
this.route(bp('/counter'), 'counter');
// this.listenTo(this, 'route', function(route, params) {
// console.log('Route: %o; params: %o', route, params);
// });
},

start: function() {
Expand Down
1 change: 1 addition & 0 deletions nsqadmin/static/js/views/base.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ var AppState = require('../app_state');

var errorTemplate = require('./error.hbs');


var BaseView = Backbone.View.extend({
constructor: function(options) {
// As of 1.10, Backbone no longer automatically attaches options passed
Expand Down
12 changes: 6 additions & 6 deletions nsqadmin/static/js/views/channel.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
{{> error}}

<ol class="breadcrumb">
<li><a class="link" href="/">Streams</a>
<li><a class="link" href="/topics/{{urlencode topic}}">{{topic}}</a>
<li><a class="link" href="{{basePath "/"}}">Streams</a>
<li><a class="link" href="{{basePath "/topics"}}/{{urlencode topic}}">{{topic}}</a>
<li class="active">{{name}}</li>
</ol>

Expand All @@ -21,7 +21,7 @@
<div class="col-md-6">
<div class="alert alert-warning">
<h4>Notice</h4> No producers exist for this topic/channel.
<p>See <a class="link" href="/lookup">Lookup</a> for more information.
<p>See <a class="link" href="{{basePath "/lookup"}}">Lookup</a> for more information.
</div>
</div>
</div>
Expand Down Expand Up @@ -75,9 +75,9 @@
<tr>
<td>
{{#if show_broadcast_address}}
{{hostname_port}} (<a class="link" href="/nodes/{{node}}">{{node}}</a>)
{{hostname_port}} (<a class="link" href="{{basePath "/nodes"}}/{{node}}">{{node}}</a>)
{{else}}
<a class="link" href="/nodes/{{node}}">{{hostname_port}}</a>
<a class="link" href="{{basePath "/nodes"}}/{{node}}">{{hostname_port}}</a>
{{/if}}
{{#if paused}} <span class="label label-primary">paused</span>{{/if}}
</td>
Expand Down Expand Up @@ -210,7 +210,7 @@
</span>
{{/if}}
</td>
<td><a class="link" href="/nodes/{{node}}">{{node}}</a></td>
<td><a class="link" href="{{basePath "/nodes"}}/{{node}}">{{node}}</a></td>
<td>{{commafy in_flight_count}}</td>
<td>{{commafy ready_count}}</td>
<td>{{commafy finish_count}}</td>
Expand Down
Loading