go.ligato.io/vpp-agent/v3@v3.5.0/plugins/kvscheduler/rest.go (about)

     1  // Copyright (c) 2018 Cisco and/or its affiliates.
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at:
     6  //
     7  //     http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  package kvscheduler
    16  
    17  import (
    18  	"context"
    19  	"errors"
    20  	"fmt"
    21  	"net/http"
    22  	"net/url"
    23  	"sort"
    24  	"strconv"
    25  	"strings"
    26  	"time"
    27  
    28  	"github.com/unrolled/render"
    29  
    30  	"go.ligato.io/cn-infra/v2/rpc/rest"
    31  
    32  	kvs "go.ligato.io/vpp-agent/v3/plugins/kvscheduler/api"
    33  	"go.ligato.io/vpp-agent/v3/plugins/kvscheduler/internal/graph"
    34  	"go.ligato.io/vpp-agent/v3/plugins/kvscheduler/internal/utils"
    35  	"go.ligato.io/vpp-agent/v3/proto/ligato/kvscheduler"
    36  )
    37  
    38  const (
    39  	// prefix used for REST urls of the KVScheduler.
    40  	urlPrefix = "/scheduler/"
    41  
    42  	// txnHistoryURL is URL used to obtain the transaction history.
    43  	txnHistoryURL = urlPrefix + "txn-history"
    44  
    45  	// sinceArg is the name of the argument used to define the start of the time
    46  	// window for the transaction history to display.
    47  	sinceArg = "since"
    48  
    49  	// untilArg is the name of the argument used to define the end of the time
    50  	// window for the transaction history to display.
    51  	untilArg = "until"
    52  
    53  	// seqNumArg is the name of the argument used to define the sequence number
    54  	// of the transaction to display (txnHistoryURL).
    55  	seqNumArg = "seq-num"
    56  
    57  	// formatArg is the name of the argument used to set the output format
    58  	// for the transaction history API.
    59  	formatArg = "format"
    60  
    61  	// recognized formats:
    62  	formatJSON = "json"
    63  	formatText = "text"
    64  
    65  	// keyTimelineURL is URL used to obtain timeline of value changes for a given key.
    66  	keyTimelineURL = urlPrefix + "key-timeline"
    67  
    68  	// keyArg is the name of the argument used to define key for "key-timeline" and "status" API.
    69  	keyArg = "key"
    70  
    71  	// graphSnapshotURL is URL used to obtain graph snapshot from a given point in time.
    72  	graphSnapshotURL = urlPrefix + "graph-snapshot"
    73  
    74  	// flagStatsURL is URL used to obtain flag statistics.
    75  	flagStatsURL = urlPrefix + "flag-stats"
    76  
    77  	// flagArg is the name of the argument used to define flag for "flag-stats" API.
    78  	flagArg = "flag"
    79  
    80  	// prefixArg is the name of the argument used to define prefix to filter keys
    81  	// for "flag-stats" API.
    82  	prefixArg = "prefix"
    83  
    84  	// time is the name of the argument used to define point in time for a graph snapshot
    85  	// to retrieve. Value = number of nanoseconds since the start of the epoch.
    86  	timeArg = "time"
    87  
    88  	// downstreamResyncURL is URL used to trigger downstream-resync.
    89  	downstreamResyncURL = urlPrefix + "downstream-resync"
    90  
    91  	// retryArg is the name of the argument used for "downstream-resync" API to tell whether
    92  	// to retry failed operations or not.
    93  	retryArg = "retry"
    94  
    95  	// verboseArg is the name of the argument used for "downstream-resync" API
    96  	// to tell whether the refreshed graph should be printed to stdout or not.
    97  	verboseArg = "verbose"
    98  
    99  	// dumpURL is URL used to dump either SB or scheduler's internal state of kv-pairs
   100  	// under the given descriptor / key-prefix.
   101  	dumpURL = urlPrefix + "dump"
   102  
   103  	// descriptorArg is the name of the argument used to define descriptor for "dump" API.
   104  	descriptorArg = "descriptor"
   105  
   106  	// keyPrefixArg is the name of the argument used to define key prefix for "dump" API.
   107  	keyPrefixArg = "key-prefix"
   108  
   109  	// viewArg is the name of the argument used for "dump" API to chooses from
   110  	// which point of view to look at the key-value space when dumping values.
   111  	// See type View from kvscheduler's API to learn the set of possible values.
   112  	viewArg = "view"
   113  
   114  	// txnArg allows to display graph at the time when the referenced transaction
   115  	// has just finalized
   116  	txnArg = "txn" // value = txn sequence number
   117  
   118  	// statusURL is URL used to print the state of values under the given
   119  	// descriptor / key-prefix or all of them.
   120  	statusURL = urlPrefix + "status"
   121  )
   122  
   123  // errorString wraps string representation of an error that, unlike the original
   124  // error, can be marshalled.
   125  type errorString struct {
   126  	Error string
   127  }
   128  
   129  // dumpIndex defines "index" page for the Dump REST API.
   130  type dumpIndex struct {
   131  	Descriptors []string
   132  	KeyPrefixes []string
   133  	Views       []string
   134  }
   135  
   136  // recordKVsWithMetadata converts a list of key-value pairs with metadata
   137  // into an equivalent list with proto.Message recorded for proper marshalling.
   138  func recordKVsWithMetadata(in []kvs.KVWithMetadata) (out []kvs.RecordedKVWithMetadata) {
   139  	for _, kv := range in {
   140  		out = append(out, kvs.RecordedKVWithMetadata{
   141  			RecordedKVPair: kvs.RecordedKVPair{
   142  				Key:    kv.Key,
   143  				Value:  utils.RecordProtoMessage(kv.Value),
   144  				Origin: kv.Origin,
   145  			},
   146  			Metadata: kv.Metadata,
   147  		})
   148  	}
   149  	return out
   150  }
   151  
   152  // registerHandlers registers all supported REST APIs.
   153  func (s *Scheduler) registerHandlers(http rest.HTTPHandlers) {
   154  	if http == nil {
   155  		s.Log.Debug("No http handler provided, skipping registration of KVScheduler REST handlers")
   156  		return
   157  	}
   158  	http.RegisterHTTPHandler(txnHistoryURL, s.txnHistoryGetHandler, "GET")
   159  	http.RegisterHTTPHandler(keyTimelineURL, s.keyTimelineGetHandler, "GET")
   160  	http.RegisterHTTPHandler(graphSnapshotURL, s.graphSnapshotGetHandler, "GET")
   161  	http.RegisterHTTPHandler(flagStatsURL, s.flagStatsGetHandler, "GET")
   162  	http.RegisterHTTPHandler(downstreamResyncURL, s.downstreamResyncPostHandler, "POST")
   163  	http.RegisterHTTPHandler(dumpURL, s.dumpGetHandler, "GET")
   164  	http.RegisterHTTPHandler(statusURL, s.statusGetHandler, "GET")
   165  	http.RegisterHTTPHandler(urlPrefix+"graph", s.graphHandler, "GET")
   166  	http.RegisterHTTPHandler(urlPrefix+"stats", s.statsHandler, "GET")
   167  }
   168  
   169  // txnHistoryGetHandler is the GET handler for "txn-history" API.
   170  func (s *Scheduler) txnHistoryGetHandler(formatter *render.Render) http.HandlerFunc {
   171  	return func(w http.ResponseWriter, req *http.Request) {
   172  		var since, until time.Time
   173  		var seqNum uint64
   174  		args := req.URL.Query()
   175  
   176  		// parse optional *format* argument (default = JSON)
   177  		format := formatJSON
   178  		if formatStr, withFormat := args[formatArg]; withFormat && len(formatStr) == 1 {
   179  			format = formatStr[0]
   180  			if format != formatJSON && format != formatText {
   181  				err := errors.New("unrecognized output format")
   182  				s.logError(formatter.JSON(w, http.StatusInternalServerError, errorString{err.Error()}))
   183  				return
   184  			}
   185  		}
   186  
   187  		// parse optional *seq-num* argument
   188  		if seqNumStr, withSeqNum := args[seqNumArg]; withSeqNum && len(seqNumStr) == 1 {
   189  			var err error
   190  			seqNum, err = strconv.ParseUint(seqNumStr[0], 10, 64)
   191  			if err != nil {
   192  				s.logError(formatter.JSON(w, http.StatusInternalServerError, errorString{err.Error()}))
   193  				return
   194  			}
   195  
   196  			// sequence number takes precedence over the since-until time window
   197  			txn := s.GetRecordedTransaction(seqNum)
   198  			if txn == nil {
   199  				err := errors.New("transaction with such sequence number is not recorded")
   200  				s.logError(formatter.JSON(w, http.StatusNotFound, errorString{err.Error()}))
   201  				return
   202  			}
   203  
   204  			if format == formatJSON {
   205  				s.logError(formatter.JSON(w, http.StatusOK, txn))
   206  			} else {
   207  				s.logError(formatter.Text(w, http.StatusOK, txn.StringWithOpts(false, true, 0)))
   208  			}
   209  			return
   210  		}
   211  
   212  		// parse optional *until* argument
   213  		if untilStr, withUntil := args[untilArg]; withUntil && len(untilStr) == 1 {
   214  			var err error
   215  			until, err = stringToTime(untilStr[0])
   216  			if err != nil {
   217  				s.logError(formatter.JSON(w, http.StatusInternalServerError, errorString{err.Error()}))
   218  				return
   219  			}
   220  		}
   221  
   222  		// parse optional *since* argument
   223  		if sinceStr, withSince := args[sinceArg]; withSince && len(sinceStr) == 1 {
   224  			var err error
   225  			since, err = stringToTime(sinceStr[0])
   226  			if err != nil {
   227  				s.logError(formatter.JSON(w, http.StatusInternalServerError, errorString{err.Error()}))
   228  				return
   229  			}
   230  		}
   231  
   232  		txnHistory := s.GetTransactionHistory(since, until)
   233  		if format == formatJSON {
   234  			s.logError(formatter.JSON(w, http.StatusOK, txnHistory))
   235  		} else {
   236  			s.logError(formatter.Text(w, http.StatusOK, txnHistory.StringWithOpts(false, false, 0)))
   237  		}
   238  	}
   239  }
   240  
   241  // keyTimelineGetHandler is the GET handler for "key-timeline" API.
   242  func (s *Scheduler) keyTimelineGetHandler(formatter *render.Render) http.HandlerFunc {
   243  	return func(w http.ResponseWriter, req *http.Request) {
   244  		args := req.URL.Query()
   245  
   246  		// parse optional *time* argument
   247  		var timeVal time.Time
   248  		if timeStr, withTime := args[timeArg]; withTime && len(timeStr) == 1 {
   249  			var err error
   250  			timeVal, err = stringToTime(timeStr[0])
   251  			if err != nil {
   252  				s.logError(formatter.JSON(w, http.StatusInternalServerError, errorString{err.Error()}))
   253  				return
   254  			}
   255  		}
   256  
   257  		// parse mandatory *key* argument
   258  		if keys, withKey := args[keyArg]; withKey && len(keys) == 1 {
   259  			graphR := s.graph.Read()
   260  			defer graphR.Release()
   261  
   262  			timeline := graphR.GetNodeTimeline(keys[0])
   263  			if !timeVal.IsZero() {
   264  				var nodeRecord *graph.RecordedNode
   265  				for _, record := range timeline {
   266  					if record.Since.Before(timeVal) &&
   267  						(record.Until.IsZero() || record.Until.After(timeVal)) {
   268  						nodeRecord = record
   269  						break
   270  					}
   271  				}
   272  				s.logError(formatter.JSON(w, http.StatusOK, nodeRecord))
   273  				return
   274  			}
   275  			s.logError(formatter.JSON(w, http.StatusOK, timeline))
   276  			return
   277  		}
   278  
   279  		err := errors.New("missing key argument")
   280  		s.logError(formatter.JSON(w, http.StatusInternalServerError, errorString{err.Error()}))
   281  	}
   282  }
   283  
   284  // graphSnapshotGetHandler is the GET handler for "graph-snapshot" API.
   285  func (s *Scheduler) graphSnapshotGetHandler(formatter *render.Render) http.HandlerFunc {
   286  	return func(w http.ResponseWriter, req *http.Request) {
   287  		timeVal := time.Now()
   288  		args := req.URL.Query()
   289  
   290  		// parse optional *time* argument
   291  		if timeStr, withTime := args[timeArg]; withTime && len(timeStr) == 1 {
   292  			var err error
   293  			timeVal, err = stringToTime(timeStr[0])
   294  			if err != nil {
   295  				s.logError(formatter.JSON(w, http.StatusInternalServerError, errorString{err.Error()}))
   296  				return
   297  			}
   298  		}
   299  
   300  		graphR := s.graph.Read()
   301  		defer graphR.Release()
   302  
   303  		snapshot := graphR.GetSnapshot(timeVal)
   304  		s.logError(formatter.JSON(w, http.StatusOK, snapshot))
   305  	}
   306  }
   307  
   308  // flagStatsGetHandler is the GET handler for "flag-stats" API.
   309  func (s *Scheduler) flagStatsGetHandler(formatter *render.Render) http.HandlerFunc {
   310  	return func(w http.ResponseWriter, req *http.Request) {
   311  		args := req.URL.Query()
   312  
   313  		// parse repeated *prefix* argument
   314  		prefixes := args[prefixArg]
   315  
   316  		if flags, withFlag := args[flagArg]; withFlag && len(flags) == 1 {
   317  			graphR := s.graph.Read()
   318  			defer graphR.Release()
   319  
   320  			stats := graphR.GetFlagStats(flagNameToIndex(flags[0]), func(key string) bool {
   321  				if len(prefixes) == 0 {
   322  					return true
   323  				}
   324  				for _, prefix := range prefixes {
   325  					if strings.HasPrefix(key, prefix) {
   326  						return true
   327  					}
   328  				}
   329  				return false
   330  			})
   331  			s.logError(formatter.JSON(w, http.StatusOK, stats))
   332  			return
   333  		}
   334  
   335  		err := errors.New("missing flag argument")
   336  		s.logError(formatter.JSON(w, http.StatusInternalServerError, errorString{err.Error()}))
   337  	}
   338  }
   339  
   340  // downstreamResyncPostHandler is the POST handler for "downstream-resync" API.
   341  func (s *Scheduler) downstreamResyncPostHandler(formatter *render.Render) http.HandlerFunc {
   342  	return func(w http.ResponseWriter, req *http.Request) {
   343  		// parse optional *retry* argument
   344  		args := req.URL.Query()
   345  		retry := false
   346  		if retryStr, withRetry := args[retryArg]; withRetry && len(retryStr) == 1 {
   347  			retryVal := retryStr[0]
   348  			if retryVal == "true" || retryVal == "1" {
   349  				retry = true
   350  			}
   351  		}
   352  
   353  		// parse optional *verbose* argument
   354  		verbose := false
   355  		if verboseStr, withVerbose := args[verboseArg]; withVerbose && len(verboseStr) == 1 {
   356  			verboseVal := verboseStr[0]
   357  			if verboseVal == "true" || verboseVal == "1" {
   358  				verbose = true
   359  			}
   360  		}
   361  
   362  		ctx := context.Background()
   363  		ctx = kvs.WithResync(ctx, kvs.DownstreamResync, verbose)
   364  		if retry {
   365  			ctx = kvs.WithRetryDefault(ctx)
   366  		}
   367  		seqNum, err := s.StartNBTransaction().Commit(ctx)
   368  		if err != nil {
   369  			s.logError(formatter.JSON(w, http.StatusInternalServerError, errorString{err.Error()}))
   370  			return
   371  		}
   372  		txn := s.GetRecordedTransaction(seqNum)
   373  		s.logError(formatter.JSON(w, http.StatusOK, txn))
   374  	}
   375  }
   376  
   377  func parseDumpAndStatusCommonArgs(args url.Values) (descriptor, keyPrefix, key string, err error) {
   378  	// parse optional *descriptor* argument
   379  	descriptors, withDescriptor := args[descriptorArg]
   380  	if withDescriptor && len(descriptors) != 1 {
   381  		err = errors.New("descriptor argument listed more than once")
   382  		return
   383  	}
   384  	if withDescriptor {
   385  		descriptor = descriptors[0]
   386  	}
   387  
   388  	// parse optional *key-prefix* argument
   389  	keyPrefixes, withKeyPrefix := args[keyPrefixArg]
   390  	if withKeyPrefix && len(keyPrefixes) != 1 {
   391  		err = errors.New("key-prefix argument listed more than once")
   392  		return
   393  	}
   394  	if withKeyPrefix {
   395  		keyPrefix = keyPrefixes[0]
   396  	}
   397  
   398  	// parse optional *key* argument
   399  	keys, withKey := args[keyArg]
   400  	if withKey && len(keys) != 1 {
   401  		err = errors.New("key argument listed more than once")
   402  		return
   403  	}
   404  	if withKey {
   405  		key = keys[0]
   406  	}
   407  	return
   408  }
   409  
   410  // dumpGetHandler is the GET handler for "dump" API.
   411  func (s *Scheduler) dumpGetHandler(formatter *render.Render) http.HandlerFunc {
   412  	return func(w http.ResponseWriter, req *http.Request) {
   413  		args := req.URL.Query()
   414  
   415  		descriptor, keyPrefix, _, err := parseDumpAndStatusCommonArgs(args)
   416  		if err != nil {
   417  			s.logError(formatter.JSON(w, http.StatusInternalServerError, errorString{err.Error()}))
   418  			return
   419  		}
   420  
   421  		// without descriptor and key prefix return "index" page
   422  		if descriptor == "" && keyPrefix == "" {
   423  			s.txnLock.Lock()
   424  			defer s.txnLock.Unlock()
   425  			index := dumpIndex{Views: []string{
   426  				kvs.SBView.String(), kvs.NBView.String(), kvs.CachedView.String()}}
   427  			for _, descriptor := range s.registry.GetAllDescriptors() {
   428  				index.Descriptors = append(index.Descriptors, descriptor.Name)
   429  				index.KeyPrefixes = append(index.KeyPrefixes, descriptor.NBKeyPrefix)
   430  			}
   431  			s.logError(formatter.JSON(w, http.StatusOK, index))
   432  			return
   433  		}
   434  
   435  		// parse optional *view* argument (default = SBView)
   436  		var view kvs.View
   437  		if viewStr, withState := args[viewArg]; withState && len(viewStr) == 1 {
   438  			switch viewStr[0] {
   439  			case kvs.SBView.String():
   440  				view = kvs.SBView
   441  			case kvs.NBView.String():
   442  				view = kvs.NBView
   443  			case kvs.CachedView.String():
   444  				view = kvs.CachedView
   445  			default:
   446  				err := errors.New("unrecognized system view")
   447  				s.logError(formatter.JSON(w, http.StatusInternalServerError, errorString{err.Error()}))
   448  				return
   449  			}
   450  		}
   451  
   452  		var dump []kvs.KVWithMetadata
   453  		if descriptor != "" {
   454  			dump, err = s.DumpValuesByDescriptor(descriptor, view)
   455  
   456  			if err != nil {
   457  				s.logError(formatter.JSON(w, http.StatusInternalServerError, errorString{err.Error()}))
   458  				return
   459  			}
   460  		} else {
   461  			dump, err = s.DumpValuesByKeyPrefix(keyPrefix, view)
   462  
   463  			if err != nil {
   464  				s.logError(formatter.JSON(w, http.StatusNotFound, errorString{err.Error()}))
   465  				return
   466  			}
   467  		}
   468  		s.logError(formatter.JSON(w, http.StatusOK, recordKVsWithMetadata(dump)))
   469  	}
   470  }
   471  
   472  // statusGetHandler is the GET handler for "status" API.
   473  func (s *Scheduler) statusGetHandler(formatter *render.Render) http.HandlerFunc {
   474  	return func(w http.ResponseWriter, req *http.Request) {
   475  		args := req.URL.Query()
   476  
   477  		descriptor, keyPrefix, key, err := parseDumpAndStatusCommonArgs(args)
   478  		if err != nil {
   479  			s.logError(formatter.JSON(w, http.StatusInternalServerError, errorString{err.Error()}))
   480  			return
   481  		}
   482  
   483  		graphR := s.graph.Read()
   484  		defer graphR.Release()
   485  
   486  		if key != "" {
   487  			singleStatus := getValueStatus(graphR.GetNode(key), key)
   488  			s.logError(formatter.JSON(w, http.StatusOK, singleStatus))
   489  			return
   490  		}
   491  
   492  		if descriptor == "" && keyPrefix != "" {
   493  			descriptor = s.getDescriptorForKeyPrefix(keyPrefix)
   494  			if descriptor == "" {
   495  				err = errors.New("unknown key prefix")
   496  				s.logError(formatter.JSON(w, http.StatusInternalServerError, errorString{err.Error()}))
   497  				return
   498  			}
   499  		}
   500  
   501  		var nodes []graph.Node
   502  		if descriptor == "" {
   503  			// get all nodes with base values
   504  			nodes = graphR.GetNodes(nil, graph.WithoutFlags(&DerivedFlag{}))
   505  		} else {
   506  			// get nodes with base values under the given descriptor
   507  			nodes = graphR.GetNodes(nil,
   508  				graph.WithFlags(&DescriptorFlag{descriptor}),
   509  				graph.WithoutFlags(&DerivedFlag{}))
   510  		}
   511  
   512  		var status []*kvscheduler.BaseValueStatus
   513  		for _, node := range nodes {
   514  			status = append(status, getValueStatus(node, node.GetKey()))
   515  		}
   516  		// sort by keys
   517  		sort.Slice(status, func(i, j int) bool {
   518  			return status[i].Value.Key < status[j].Value.Key
   519  		})
   520  		s.logError(formatter.JSON(w, http.StatusOK, status))
   521  	}
   522  }
   523  
   524  func (s *Scheduler) graphHandler(formatter *render.Render) http.HandlerFunc {
   525  	return func(w http.ResponseWriter, req *http.Request) {
   526  		args := req.URL.Query()
   527  		s.txnLock.Lock()
   528  		defer s.txnLock.Unlock()
   529  		graphRead := s.graph.Read()
   530  		defer graphRead.Release()
   531  
   532  		var txn *kvs.RecordedTxn
   533  		timestamp := time.Now()
   534  
   535  		// parse optional *txn* argument
   536  		if txnStr, withTxn := args[txnArg]; withTxn && len(txnStr) == 1 {
   537  			txnSeqNum, err := strconv.ParseUint(txnStr[0], 10, 64)
   538  			if err != nil {
   539  				s.logError(formatter.JSON(w, http.StatusInternalServerError, errorString{err.Error()}))
   540  				return
   541  			}
   542  
   543  			txn = s.GetRecordedTransaction(txnSeqNum)
   544  			if txn == nil {
   545  				err := errors.New("transaction with such sequence number is not recorded")
   546  				s.logError(formatter.JSON(w, http.StatusNotFound, errorString{err.Error()}))
   547  				return
   548  			}
   549  			timestamp = txn.Stop
   550  		}
   551  
   552  		graphSnapshot := graphRead.GetSnapshot(timestamp)
   553  		output, err := s.renderDotOutput(graphSnapshot, txn)
   554  		if err != nil {
   555  			http.Error(w, err.Error(), http.StatusInternalServerError)
   556  			return
   557  		}
   558  
   559  		format := req.FormValue("format")
   560  		switch format {
   561  		case "raw":
   562  			_, err = w.Write(output)
   563  			if err != nil {
   564  				http.Error(w, err.Error(), http.StatusInternalServerError)
   565  			}
   566  			return
   567  		case "dot":
   568  			dot, err := validateDot(output)
   569  			if err != nil {
   570  				http.Error(w, err.Error(), http.StatusInternalServerError)
   571  				return
   572  			}
   573  			_, err = w.Write(dot)
   574  			if err != nil {
   575  				http.Error(w, err.Error(), http.StatusInternalServerError)
   576  			}
   577  			return
   578  		default:
   579  			format = "svg"
   580  		}
   581  
   582  		img, err := dotToImage("", format, output)
   583  		if err != nil {
   584  			http.Error(w, fmt.Sprintf("rendering image %v failed: %v\n%s", img, err, output), http.StatusInternalServerError)
   585  			return
   586  		}
   587  
   588  		s.Log.Debug("serving graph image from:", img)
   589  		http.ServeFile(w, req, img)
   590  	}
   591  }
   592  
   593  func (s *Scheduler) statsHandler(formatter *render.Render) http.HandlerFunc {
   594  	return func(w http.ResponseWriter, req *http.Request) {
   595  		s.logError(formatter.JSON(w, http.StatusOK, GetStats()))
   596  	}
   597  }
   598  
   599  // logError logs non-nil errors from JSON formatter
   600  func (s *Scheduler) logError(err error) {
   601  	if err != nil {
   602  		s.Log.Error(err)
   603  	}
   604  }
   605  
   606  // stringToTime converts Unix timestamp from string to time.Time.
   607  func stringToTime(s string) (time.Time, error) {
   608  	nsec, err := strconv.ParseInt(s, 10, 64)
   609  	if err != nil {
   610  		return time.Time{}, err
   611  	}
   612  	return time.Unix(0, nsec), nil
   613  }