diff --git a/api/api.go b/api/api.go index e864cfe..7049e3a 100644 --- a/api/api.go +++ b/api/api.go @@ -23,6 +23,8 @@ import ( "nuts-foundation/nuts-monitor/client" "nuts-foundation/nuts-monitor/client/diagnostics" "nuts-foundation/nuts-monitor/config" + "nuts-foundation/nuts-monitor/data" + "time" ) var _ StrictServerInterface = (*Wrapper)(nil) @@ -33,8 +35,9 @@ const ( ) type Wrapper struct { - Config config.Config - Client client.HTTPClient + Config config.Config + Client client.HTTPClient + DataStore *data.Store } func (w Wrapper) Diagnostics(ctx context.Context, _ DiagnosticsRequestObject) (DiagnosticsResponseObject, error) { @@ -97,3 +100,32 @@ func (w Wrapper) NetworkTopology(ctx context.Context, _ NetworkTopologyRequestOb return NetworkTopology200JSONResponse(networkTopology), nil } + +func (w Wrapper) GetWebTransactionsAggregated(ctx context.Context, _ GetWebTransactionsAggregatedRequestObject) (GetWebTransactionsAggregatedResponseObject, error) { + // get data from the store + dataPoints := w.DataStore.GetTransactions() + + // convert the data points to the response object + response := AggregatedTransactions{} + // loop over the 3 categories of data points + // for each category, loop over the data points and add them to the correct category in the response object + for _, dp := range dataPoints[0] { + response.Hourly = append(response.Hourly, toDataPoint(dp)) + } + for _, dp := range dataPoints[1] { + response.Daily = append(response.Daily, toDataPoint(dp)) + } + for _, dp := range dataPoints[2] { + response.Monthly = append(response.Monthly, toDataPoint(dp)) + } + + return GetWebTransactionsAggregated200JSONResponse(response), nil +} + +func toDataPoint(dp data.DataPoint) DataPoint { + return DataPoint{ + Timestamp: int(dp.Timestamp.Unix()), + Label: dp.Timestamp.Format(time.RFC3339), + Value: int(dp.Count), + } +} diff --git a/api/api.yaml b/api/api.yaml index 2281a47..19a345f 100644 --- a/api/api.yaml +++ b/api/api.yaml @@ -1,6 +1,6 @@ openapi: 3.0.0 info: - title: Nuts Registry Admin API + title: Nuts Monitor API version: 1.0.0 paths: @@ -44,8 +44,47 @@ paths: application/json: schema: $ref: "#/components/schemas/NetworkTopology" + /web/transactions/aggregated: + get: + summary: "Returns the transactions aggregated by time" + description: > + Returns the transactions aggregated by time. It contains three sets of data points: + - an interval of 1 hour with a resolution of 1 minute + - an interval of 1 day with a resolution of 1 hour + - an interval of 1 month with a resolution of 1 day + operationId: aggregatedTransactions + responses: + 200: + description: "Aggregated transactions data" + content: + application/json: + schema: + $ref: "#/components/schemas/AggregatedTransactions" components: schemas: + AggregatedTransactions: + type: object + description: "Aggregated transactions data" + required: + - hourly + - daily + - monthly + properties: + hourly: + type: array + description: "Aggregated transactions data for the last hour" + items: + $ref: "#/components/schemas/DataPoint" + daily: + type: array + description: "Aggregated transactions data for the last day" + items: + $ref: "#/components/schemas/DataPoint" + monthly: + type: array + description: "Aggregated transactions data for the last month" + items: + $ref: "#/components/schemas/DataPoint" CheckHealthResponse: required: - status @@ -59,6 +98,23 @@ components: description: Map of the performed health checks and their results. additionalProperties: $ref: "#/components/schemas/HealthCheckResult" + DataPoint: + type: object + description: "Data point" + required: + - timestamp + - label + - value + properties: + timestamp: + type: integer + description: "time of the data point formatted as unix timestamp" + label: + type: string + description: "time of the data point formatted as RFC3339" + value: + type: integer + description: "number of transactions between the given timestamp and the next timestamp" Diagnostics: required: - network diff --git a/api/generated.go b/api/generated.go index c596b89..3f7928f 100644 --- a/api/generated.go +++ b/api/generated.go @@ -12,27 +12,33 @@ import ( "github.com/labstack/echo/v4" ) -// ConnectedPeer information on a single connected peer -type ConnectedPeer struct { - // Address domain or IP address of connected node - Address string `json:"address"` +// AggregatedTransactions Aggregated transactions data +type AggregatedTransactions struct { + // Daily Aggregated transactions data for the last day + Daily []DataPoint `json:"daily"` - // Authenticated True if NodeDID and certificate are correctly configured - Authenticated bool `json:"authenticated"` + // Hourly Aggregated transactions data for the last hour + Hourly []DataPoint `json:"hourly"` - // Id PeerID aka UUID of a node - Id string `json:"id"` + // Monthly Aggregated transactions data for the last month + Monthly []DataPoint `json:"monthly"` +} + +// DataPoint Data point +type DataPoint struct { + // Label time of the data point formatted as RFC3339 + Label string `json:"label"` + + // Timestamp time of the data point formatted as unix timestamp + Timestamp int `json:"timestamp"` - // Nodedid NodeDID if connection is authenticated - Nodedid *string `json:"nodedid,omitempty"` + // Value number of transactions at the given timestamp + Value int `json:"value"` } // Network network and connection diagnostics type Network struct { Connections struct { - // ConnectedPeers information on a single connected peer - ConnectedPeers ConnectedPeer `json:"connected_peers"` - // ConnectedPeersCount number of peers connected ConnectedPeersCount int `json:"connected_peers_count"` @@ -122,6 +128,9 @@ type ServerInterface interface { // Returns the network as a graph model // (GET /web/network_topology) NetworkTopology(ctx echo.Context) error + // Returns the transactions aggregated by time + // (GET /web/transactions/aggregated) + GetWebTransactionsAggregated(ctx echo.Context) error } // ServerInterfaceWrapper converts echo contexts to parameters. @@ -156,6 +165,15 @@ func (w *ServerInterfaceWrapper) NetworkTopology(ctx echo.Context) error { return err } +// GetWebTransactionsAggregated converts echo context to params. +func (w *ServerInterfaceWrapper) GetWebTransactionsAggregated(ctx echo.Context) error { + var err error + + // Invoke the callback with all the unmarshalled arguments + err = w.Handler.GetWebTransactionsAggregated(ctx) + return err +} + // This is a simple interface which specifies echo.Route addition functions which // are present on both echo.Echo and echo.Group, since we want to allow using // either of them for path registration @@ -187,6 +205,7 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL router.GET(baseURL+"/health", wrapper.CheckHealth) router.GET(baseURL+"/web/diagnostics", wrapper.Diagnostics) router.GET(baseURL+"/web/network_topology", wrapper.NetworkTopology) + router.GET(baseURL+"/web/transactions/aggregated", wrapper.GetWebTransactionsAggregated) } @@ -247,6 +266,22 @@ func (response NetworkTopology200JSONResponse) VisitNetworkTopologyResponse(w ht return json.NewEncoder(w).Encode(response) } +type GetWebTransactionsAggregatedRequestObject struct { +} + +type GetWebTransactionsAggregatedResponseObject interface { + VisitGetWebTransactionsAggregatedResponse(w http.ResponseWriter) error +} + +type GetWebTransactionsAggregated200JSONResponse AggregatedTransactions + +func (response GetWebTransactionsAggregated200JSONResponse) VisitGetWebTransactionsAggregatedResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + // StrictServerInterface represents all server handlers. type StrictServerInterface interface { // More elaborate health check to conform the app is (probably) functioning correctly @@ -258,6 +293,9 @@ type StrictServerInterface interface { // Returns the network as a graph model // (GET /web/network_topology) NetworkTopology(ctx context.Context, request NetworkTopologyRequestObject) (NetworkTopologyResponseObject, error) + // Returns the transactions aggregated by time + // (GET /web/transactions/aggregated) + GetWebTransactionsAggregated(ctx context.Context, request GetWebTransactionsAggregatedRequestObject) (GetWebTransactionsAggregatedResponseObject, error) } type StrictHandlerFunc func(ctx echo.Context, args interface{}) (interface{}, error) @@ -341,3 +379,26 @@ func (sh *strictHandler) NetworkTopology(ctx echo.Context) error { } return nil } + +// GetWebTransactionsAggregated operation middleware +func (sh *strictHandler) GetWebTransactionsAggregated(ctx echo.Context) error { + var request GetWebTransactionsAggregatedRequestObject + + handler := func(ctx echo.Context, request interface{}) (interface{}, error) { + return sh.ssi.GetWebTransactionsAggregated(ctx.Request().Context(), request.(GetWebTransactionsAggregatedRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetWebTransactionsAggregated") + } + + response, err := handler(ctx, request) + + if err != nil { + return err + } else if validResponse, ok := response.(GetWebTransactionsAggregatedResponseObject); ok { + return validResponse.VisitGetWebTransactionsAggregatedResponse(ctx.Response()) + } else if response != nil { + return fmt.Errorf("Unexpected response type: %T", response) + } + return nil +} diff --git a/client/client.go b/client/client.go index aef5319..5b4a6ba 100644 --- a/client/client.go +++ b/client/client.go @@ -150,3 +150,27 @@ func (hb HTTPClient) DIDDocument(ctx context.Context, did string) (*vdr.DIDResol } return nil, fmt.Errorf("received incorrect response from node: %s", string(result.Body)) } + +// ListTransactions returns transactions in a certain range according to LC value +func (hb HTTPClient) ListTransactions(ctx context.Context, start int, end int) ([]string, error) { + var transactions []string + + response, err := hb.networkClient().ListTransactions(ctx, &network.ListTransactionsParams{ + Start: &start, + End: &end, + }) + if err != nil { + return transactions, err + } + if err := TestResponseCode(http.StatusOK, response); err != nil { + return transactions, err + } + parsedResponse, err := network.ParseListTransactionsResponse(response) + if err != nil { + return transactions, err + } + if parsedResponse.JSON200 != nil { + return *parsedResponse.JSON200, nil + } + return transactions, nil +} diff --git a/client/network/generated.go b/client/network/generated.go index 47df9a6..b92ba0b 100644 --- a/client/network/generated.go +++ b/client/network/generated.go @@ -91,6 +91,15 @@ type RenderGraphParams struct { End *int `form:"end,omitempty" json:"end,omitempty"` } +// ListTransactionsParams defines parameters for ListTransactions. +type ListTransactionsParams struct { + // Start Inclusive start of range (in lamport clock); default=0 + Start *int `form:"start,omitempty" json:"start,omitempty"` + + // End Exclusive stop of range (in lamport clock); default=∞ + End *int `form:"end,omitempty" json:"end,omitempty"` +} + // RequestEditorFn is the function signature for the RequestEditor callback function type RequestEditorFn func(ctx context.Context, req *http.Request) error @@ -175,6 +184,15 @@ type ClientInterface interface { // ListEvents request ListEvents(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) + + // ListTransactions request + ListTransactions(ctx context.Context, params *ListTransactionsParams, reqEditors ...RequestEditorFn) (*http.Response, error) + + // GetTransaction request + GetTransaction(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) + + // GetTransactionPayload request + GetTransactionPayload(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) } func (c *Client) GetAddressBook(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) { @@ -225,6 +243,42 @@ func (c *Client) ListEvents(ctx context.Context, reqEditors ...RequestEditorFn) return c.Client.Do(req) } +func (c *Client) ListTransactions(ctx context.Context, params *ListTransactionsParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewListTransactionsRequest(c.Server, params) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) GetTransaction(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetTransactionRequest(c.Server, ref) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) GetTransactionPayload(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetTransactionPayloadRequest(c.Server, ref) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + // NewGetAddressBookRequest generates requests for GetAddressBook func NewGetAddressBookRequest(server string) (*http.Request, error) { var err error @@ -369,6 +423,137 @@ func NewListEventsRequest(server string) (*http.Request, error) { return req, nil } +// NewListTransactionsRequest generates requests for ListTransactions +func NewListTransactionsRequest(server string, params *ListTransactionsParams) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/internal/network/v1/transaction") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + queryValues := queryURL.Query() + + if params.Start != nil { + + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "start", runtime.ParamLocationQuery, *params.Start); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + } + + if params.End != nil { + + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "end", runtime.ParamLocationQuery, *params.End); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + } + + queryURL.RawQuery = queryValues.Encode() + + req, err := http.NewRequest("GET", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewGetTransactionRequest generates requests for GetTransaction +func NewGetTransactionRequest(server string, ref string) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/internal/network/v1/transaction/%s", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("GET", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewGetTransactionPayloadRequest generates requests for GetTransactionPayload +func NewGetTransactionPayloadRequest(server string, ref string) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/internal/network/v1/transaction/%s/payload", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("GET", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + func (c *Client) applyEditors(ctx context.Context, req *http.Request, additionalEditors []RequestEditorFn) error { for _, r := range c.RequestEditors { if err := r(ctx, req); err != nil { @@ -423,6 +608,15 @@ type ClientWithResponsesInterface interface { // ListEvents request ListEventsWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*ListEventsResponse, error) + + // ListTransactions request + ListTransactionsWithResponse(ctx context.Context, params *ListTransactionsParams, reqEditors ...RequestEditorFn) (*ListTransactionsResponse, error) + + // GetTransaction request + GetTransactionWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*GetTransactionResponse, error) + + // GetTransactionPayload request + GetTransactionPayloadWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*GetTransactionPayloadResponse, error) } type GetAddressBookResponse struct { @@ -542,6 +736,100 @@ func (r ListEventsResponse) StatusCode() int { return 0 } +type ListTransactionsResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *[]string + JSONDefault *struct { + // Detail A human-readable explanation specific to this occurrence of the problem. + Detail string `json:"detail"` + + // Status HTTP statuscode + Status float32 `json:"status"` + + // Title A short, human-readable summary of the problem type. + Title string `json:"title"` + } +} + +// Status returns HTTPResponse.Status +func (r ListTransactionsResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r ListTransactionsResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type GetTransactionResponse struct { + Body []byte + HTTPResponse *http.Response + JSONDefault *struct { + // Detail A human-readable explanation specific to this occurrence of the problem. + Detail string `json:"detail"` + + // Status HTTP statuscode + Status float32 `json:"status"` + + // Title A short, human-readable summary of the problem type. + Title string `json:"title"` + } +} + +// Status returns HTTPResponse.Status +func (r GetTransactionResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetTransactionResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type GetTransactionPayloadResponse struct { + Body []byte + HTTPResponse *http.Response + JSONDefault *struct { + // Detail A human-readable explanation specific to this occurrence of the problem. + Detail string `json:"detail"` + + // Status HTTP statuscode + Status float32 `json:"status"` + + // Title A short, human-readable summary of the problem type. + Title string `json:"title"` + } +} + +// Status returns HTTPResponse.Status +func (r GetTransactionPayloadResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetTransactionPayloadResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + // GetAddressBookWithResponse request returning *GetAddressBookResponse func (c *ClientWithResponses) GetAddressBookWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetAddressBookResponse, error) { rsp, err := c.GetAddressBook(ctx, reqEditors...) @@ -578,6 +866,33 @@ func (c *ClientWithResponses) ListEventsWithResponse(ctx context.Context, reqEdi return ParseListEventsResponse(rsp) } +// ListTransactionsWithResponse request returning *ListTransactionsResponse +func (c *ClientWithResponses) ListTransactionsWithResponse(ctx context.Context, params *ListTransactionsParams, reqEditors ...RequestEditorFn) (*ListTransactionsResponse, error) { + rsp, err := c.ListTransactions(ctx, params, reqEditors...) + if err != nil { + return nil, err + } + return ParseListTransactionsResponse(rsp) +} + +// GetTransactionWithResponse request returning *GetTransactionResponse +func (c *ClientWithResponses) GetTransactionWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*GetTransactionResponse, error) { + rsp, err := c.GetTransaction(ctx, ref, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetTransactionResponse(rsp) +} + +// GetTransactionPayloadWithResponse request returning *GetTransactionPayloadResponse +func (c *ClientWithResponses) GetTransactionPayloadWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*GetTransactionPayloadResponse, error) { + rsp, err := c.GetTransactionPayload(ctx, ref, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetTransactionPayloadResponse(rsp) +} + // ParseGetAddressBookResponse parses an HTTP response from a GetAddressBookWithResponse call func ParseGetAddressBookResponse(rsp *http.Response) (*GetAddressBookResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) @@ -722,3 +1037,115 @@ func ParseListEventsResponse(rsp *http.Response) (*ListEventsResponse, error) { return response, nil } + +// ParseListTransactionsResponse parses an HTTP response from a ListTransactionsWithResponse call +func ParseListTransactionsResponse(rsp *http.Response) (*ListTransactionsResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &ListTransactionsResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest []string + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + var dest struct { + // Detail A human-readable explanation specific to this occurrence of the problem. + Detail string `json:"detail"` + + // Status HTTP statuscode + Status float32 `json:"status"` + + // Title A short, human-readable summary of the problem type. + Title string `json:"title"` + } + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSONDefault = &dest + + } + + return response, nil +} + +// ParseGetTransactionResponse parses an HTTP response from a GetTransactionWithResponse call +func ParseGetTransactionResponse(rsp *http.Response) (*GetTransactionResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetTransactionResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + var dest struct { + // Detail A human-readable explanation specific to this occurrence of the problem. + Detail string `json:"detail"` + + // Status HTTP statuscode + Status float32 `json:"status"` + + // Title A short, human-readable summary of the problem type. + Title string `json:"title"` + } + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSONDefault = &dest + + } + + return response, nil +} + +// ParseGetTransactionPayloadResponse parses an HTTP response from a GetTransactionPayloadWithResponse call +func ParseGetTransactionPayloadResponse(rsp *http.Response) (*GetTransactionPayloadResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetTransactionPayloadResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + var dest struct { + // Detail A human-readable explanation specific to this occurrence of the problem. + Detail string `json:"detail"` + + // Status HTTP statuscode + Status float32 `json:"status"` + + // Title A short, human-readable summary of the problem type. + Title string `json:"title"` + } + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSONDefault = &dest + + } + + return response, nil +} diff --git a/codegen/network-config.yaml b/codegen/network-config.yaml index 0b527ec..2055b9b 100644 --- a/codegen/network-config.yaml +++ b/codegen/network-config.yaml @@ -5,4 +5,5 @@ generate: models: true output-options: include-tags: - - diagnostics \ No newline at end of file + - diagnostics + - transactions \ No newline at end of file diff --git a/data/README.md b/data/README.md new file mode 100644 index 0000000..92f7167 --- /dev/null +++ b/data/README.md @@ -0,0 +1,11 @@ +Things todo: + +Data format +- map of DID to its controller (cache) +- map of controller to list of (type, count) +- map of type to list of (time, count) +- sliding window (3 times) + +Process +- run at startup +- listen to nats \ No newline at end of file diff --git a/data/store.go b/data/store.go new file mode 100644 index 0000000..23cc285 --- /dev/null +++ b/data/store.go @@ -0,0 +1,74 @@ +/* + * Copyright (C) 2023 Nuts community + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package data + +import ( + "context" + "time" +) + +// Store is an in-memory store that contains a mapping from transaction signer to its controller. +// It also contains three sliding windows with length and resolution of: (1 hour, 1 minute), (1 day, 1 hour), (30 days, 1 day). +// A transaction can be added, the store will resolve the signer and the controller of the signer. +type Store struct { + mapping map[string]string + slidingWindows []*slidingWindow +} + +func NewStore() *Store { + s := &Store{ + mapping: make(map[string]string), + } + + // initialize all windows with empty dataPoints using the init function + s.slidingWindows = append(s.slidingWindows, NewSlidingWindow(time.Minute, time.Hour, time.Second)) + s.slidingWindows = append(s.slidingWindows, NewSlidingWindow(time.Hour, 24*time.Hour, time.Minute)) + s.slidingWindows = append(s.slidingWindows, NewSlidingWindow(24*time.Hour, 30*24*time.Hour, time.Minute)) + + return s +} + +// Start starts the sliding windows +func (s *Store) Start(ctx context.Context) { + for i := range s.slidingWindows { + s.slidingWindows[i].Start(ctx) + } +} + +// Add a transaction to the sliding windows and resolve the controller of the signer +func (s *Store) Add(transaction Transaction) { + // first add the transaction to the sliding windows + for i := range s.slidingWindows { + s.slidingWindows[i].AddCount(transaction.SigTime) + } + + // todo: resolve the controller of the signer +} + +// GetTransactions returns the transactions of the sliding windows +// The smallest resolution is first, the largest resolution is last +func (s *Store) GetTransactions() [3][]DataPoint { + var transactions [3][]DataPoint + + for i, window := range s.slidingWindows { + transactions[i] = window.dataPoints + } + + return transactions +} diff --git a/data/transaction.go b/data/transaction.go new file mode 100644 index 0000000..74629cd --- /dev/null +++ b/data/transaction.go @@ -0,0 +1,91 @@ +/* + * Copyright (C) 2023 Nuts community + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package data + +import ( + "errors" + "github.com/lestrrat-go/jwx/jws" + "strings" + "time" +) + +// ErrInvalidSigner is returned when the signer is not a valid DID signer +var ErrInvalidSigner = errors.New("invalid signer") + +// ErrNoSigTime is returned when the transaction does not contain a sigt field +var ErrNoSigTime = errors.New("no sigt field") + +// Transaction represents a Nuts transaction. +// It is parsed from a JWS token. It does not check the signature. +type Transaction struct { + // Signer is extracted from the key used to sign the transaction + Signer string + // SigTime is the signature time in seconds since the Unix epoch + SigTime time.Time +} + +func FromJWS(transaction string) (*Transaction, error) { + // we use the lestrrat-go/jwx library to parse the JWS + jwsToken, err := jws.ParseString(transaction) + if err != nil { + return nil, err + } + + // first extract the signature time from the "sigt" field + // the "sigt" field is a Unix Timestamp in seconds + // we convert it to an int64 + sigt, ok := jwsToken.Signatures()[0].ProtectedHeaders().Get("sigt") + if !ok { + return nil, ErrNoSigTime + } + // parse the sigt string value to time field + sigTime := time.Unix(int64(sigt.(float64)), 0) + + // the signer can either be extracted from the "kid" header or from the embedded key + // we first try to extract it from the "kid" header + signer, ok := jwsToken.Signatures()[0].ProtectedHeaders().Get("kid") + if ok { + // the kid is a combination of DID and key ID, we only want the DID part + // the DID is the part before the first # + // example: did:nuts:0x1234567890abcdef#key-1 -> did:nuts:0x1234567890abcdef + // check if # is contained in the string, return an error if not + index := strings.Index(signer.(string), "#") + if index == -1 { + return nil, ErrInvalidSigner + } + return &Transaction{Signer: signer.(string)[:index], SigTime: sigTime}, nil + } + + // if the "kid" header is not present, we try to extract the signer from the embedded key + // the embedded key is a JWK, we extract the "kid" header from it + // the "kid" header is a combination of DID and key ID, we only want the DID part + // the DID is the part before the first # + // example: did:nuts:0x1234567890abcdef#key-1 -> did:nuts:0x1234567890abcdef + jwk := jwsToken.Signatures()[0].ProtectedHeaders().JWK() + if jwk != nil { + kid := jwk.KeyID() + index := strings.Index(kid, "#") + if index == -1 { + return nil, ErrInvalidSigner + } + return &Transaction{Signer: kid[:index], SigTime: sigTime}, nil + } + + return &Transaction{}, nil +} diff --git a/data/transaction_test.go b/data/transaction_test.go new file mode 100644 index 0000000..d63937b --- /dev/null +++ b/data/transaction_test.go @@ -0,0 +1,75 @@ +/* + * Copyright (C) 2023 Nuts community + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +package data + +import ( + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "testing" + "time" +) + +// ExampleJWS contains a correct transaction in condensed JWS format with a jwk field +const ExampleJWS = "eyJhbGciOiJFUzI1NiIsImNyaXQiOlsic2lndCIsInZlciIsInByZXZzIiwiandrIl0sImN0eSI6ImFwcGxpY2F0aW9uL2RpZCtqc29uIiwiandrIjp7ImNydiI6IlAtMjU2Iiwia2lkIjoiZGlkOm51dHM6Q29yMzI4SjUxaE54U3V5RXVCZ2FWdVZuUXBFZ0tzOTFzTUpHYVB1M0I2SnIjcjNDM25kWHFMT0YzWkpCTkh5SVM4SFEzSjRVQmlKRGplQTRGREFRSk51OCIsImt0eSI6IkVDIiwieCI6IlpvMTRYR0pwRzIwSXdYUmFINGhjZ2p0bXUzTHF6dnNoUUlBTTZIWXZJN1UiLCJ5IjoibVJrOTZkRjVSd05Zd0tPUGxncTVxeUtoQUhkQ0UyeHM2bHFJaWtndGJJTSJ9LCJsYyI6MCwicHJldnMiOltdLCJzaWd0IjoxNjUzOTg2MTMwLCJ2ZXIiOjF9.Y2UxOTI3ZTQ1NTdjNDNmMmM1YWVkYzg1OWI4OTg3ZmY2NmI3ZDk3YjhmZmVhZDJkNjEyZDE1ZjNkNTIwMmJlOQ.PEZyffKoWPliezsUlfAm7cdcHTDCImwa5w6inVxC8QQg9swJM3ozjZEV2b3_DzOVDpN7jecvb1WeIf7PDMHTKQ" + +// ExampleJWS2 contains a correct transaction in condensed JWS format with a kid field +const ExampleJWS2 = "eyJhbGciOiJFUzI1NiIsImNyaXQiOlsic2lndCIsInZlciIsInByZXZzIiwia2lkIl0sImN0eSI6ImFwcGxpY2F0aW9uL2RpZCtqc29uIiwia2lkIjoiZGlkOm51dHM6OWJXOFZoVmsyazRXNXAxNVpmVER0cFFEYVNrNm9MNnlhR0JaenU3ZzI5UFojZjdRdDcyVlRIZzIybC0tVmZ2VWZQTkR0V0ZfWWxENmFDTUotQmdvS3l4USIsImxjIjoxMCwicHJldnMiOlsiYjA2OGRkMjc0NDFjMDBkNzU1MTdjYjUwZmJhMjIyYjczZjU5NDFlZGY4ZDNmNGQxMzk2NjBjNDkyZTZkNmZkNyJdLCJzaWd0IjoxNjU0MjQ4OTU4LCJ2ZXIiOjF9.NDBmMmY0NGYxNDUwYjBiYzE2ZGYxMzYyMzZmM2I0MDkzZDc3NTEyM2IyZGM5YWFjMzg3YzJmZGMxYzIyYTliZA.1Ak130yrjlqEGgg3HcVm1JB0iEOlnxOhIGojo9icthyg50h72ByRzKg4Pa7oWnuKH4JnIzlZXkPH0vvC6BL2Bw" + +// ExampleJWS3 contains a transaction without a sigtime field +const ExampleJWS3 = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" + +// ExampleJWS4 contains a transaction with an incorrect kid field +const ExampleJWS4 = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRpZDpudXRzOmFiY2RlZmcxMjM0NSJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.p3sW4cvesjhCCS05Uwhow12WFjH2fGKAQH5wmy5MdCQ" + +// ExampleJWS5 contains a transaction without key fields +const ExampleJWS5 = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsInNpZ3QiOjEwfQ.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.NPkBysCGI47ey7RiDi_zPl9IxN34t3Kdk1Lpw3Dzzok" + +func TestFromJWS(t *testing.T) { + t.Run("extract transaction from a valid JWS with a jwk field", func(t *testing.T) { + transaction, err := FromJWS(ExampleJWS) + + require.NoError(t, err) + assert.NotNil(t, transaction) + assert.Equal(t, time.Unix(1653986130, 0), transaction.SigTime) + }) + + t.Run("extract transaction from a valid JWS without a jwk field", func(t *testing.T) { + transaction, err := FromJWS(ExampleJWS2) + + require.NoError(t, err) + assert.NotNil(t, transaction) + }) + t.Run("extract transaction from a valid JWS without a jwk field and without a kid field", func(t *testing.T) { + transaction, err := FromJWS(ExampleJWS5) + + require.NoError(t, err) + assert.NotNil(t, transaction) + }) + t.Run("extract transaction from a valid JWS without a sigt field", func(t *testing.T) { + transaction, err := FromJWS(ExampleJWS3) + + assert.Error(t, ErrNoSigTime, err) + assert.Nil(t, transaction) + }) + t.Run("extract transaction from a JWS with an incorrect kid field", func(t *testing.T) { + transaction, err := FromJWS(ExampleJWS4) + + assert.Error(t, err) + assert.Nil(t, transaction) + }) +} diff --git a/data/window.go b/data/window.go new file mode 100644 index 0000000..c89d99e --- /dev/null +++ b/data/window.go @@ -0,0 +1,169 @@ +/* + * Copyright (C) 2023 Nuts community + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package data + +import ( + "context" + "sync" + "time" +) + +type DataPoint struct { + Timestamp time.Time + Count uint32 +} + +type slidingWindow struct { + resolution time.Duration + length time.Duration + evictionInterval time.Duration + mutex sync.Mutex + dataPoints []DataPoint + clockdrift time.Duration +} + +func NewSlidingWindow(resolution, length, evictionInterval time.Duration) *slidingWindow { + s := &slidingWindow{ + resolution: resolution, + length: length, + evictionInterval: evictionInterval, + dataPoints: []DataPoint{}, + clockdrift: 5 * time.Second, + } + + s.consolidate() + + return s +} + +func (s *slidingWindow) Start(ctx context.Context) { + done := ctx.Done() + + go func() { + ticker := time.NewTicker(s.evictionInterval) + + for { + select { + case <-done: + return + case <-ticker.C: + s.mutex.Lock() + s.consolidate() + s.mutex.Unlock() + } + } + }() +} + +// maxLength calculates the maximum number of dataPoints in the sliding window +func (s *slidingWindow) maxLength() int { + return int(s.length / s.resolution) +} + +// slide removes dataPoints older than length +func (s *slidingWindow) slide(now time.Time) { + + cutoff := -1 + for j := len(s.dataPoints) - 1; j >= 0; j-- { + if s.dataPoints[j].Timestamp.Before(now.Add(-s.length).Add(time.Nanosecond)) { + cutoff = j + 1 + break + } + } + + if cutoff == -1 { + return + } + + s.dataPoints = s.dataPoints[cutoff:] +} + +// AddCount adds +1 to the DataPoint at the correct moment +func (s *slidingWindow) AddCount(at time.Time) { + // first we apply the clockdrift + at = at.Add(-1 * s.clockdrift) + + // first we truncate the at time to the correct resolution + at = at.Truncate(s.resolution) + + // then we check if the at time is in the sliding window + if at.Before(time.Now().Add(-s.length).Truncate(s.resolution)) { + // the at time is before the sliding window, we ignore it + return + } + + // add the Count to the correct DataPoint + s.mutex.Lock() + defer s.mutex.Unlock() + + for i, dataPoint := range s.dataPoints { + if dataPoint.Timestamp.Equal(at) { + s.dataPoints[i].Count++ + return + } + } + + // no DataPoint found, create a new one + s.dataPoints = append(s.dataPoints, DataPoint{ + Timestamp: at, + Count: 1, + }) + + // consolidate the window + s.consolidate() + + return +} + +// consolidate the window, this will fill any gaps in the dataPoints slice +// it'll first truncate the slice and remove all old dataPoints +// then it'll fill the slice with new DataPoints +// and sort it afterwards +func (s *slidingWindow) consolidate() { + now := time.Now().Truncate(s.resolution) + + s.slide(now) + + // first create a new slice with maxLen + newDataPoints := make([]DataPoint, s.maxLength()) + + // prefill the new slice with DataPoints + for i := len(newDataPoints) - 1; i >= 0; i-- { + newDataPoints[i] = DataPoint{ + Timestamp: now.Add(time.Duration(i-len(newDataPoints)+1) * s.resolution), + Count: 0, + } + } + + // then loop over the current dataPoints and add the count from those points to the correct new DataPoint + for _, dataPoint := range s.dataPoints { + index := s.toIndex(dataPoint, now) + if index < len(newDataPoints) { // huge clockdrift can cause this, so ignore it + newDataPoints[index].Count += dataPoint.Count + } + } + + // set the new dataPoints + s.dataPoints = newDataPoints +} + +// toIndex converts a datapoint to an index in the dataPoints slice +func (s *slidingWindow) toIndex(dp DataPoint, now time.Time) int { + return s.maxLength() - int(now.Sub(dp.Timestamp)/s.resolution) - 1 +} diff --git a/data/window_test.go b/data/window_test.go new file mode 100644 index 0000000..2058b95 --- /dev/null +++ b/data/window_test.go @@ -0,0 +1,184 @@ +/* + * Copyright (C) 2023 Nuts community + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package data + +import ( + "context" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "testing" + "time" +) + +func TestSlidingWindow_AddCount(t *testing.T) { + t.Run("adds a new DataPoint", func(t *testing.T) { + now := time.Now() + + window := slidingWindow{ + resolution: time.Second, + length: time.Second * 10, + } + + window.AddCount(now) + + assert.Len(t, window.dataPoints, 10) + assert.Equal(t, now.Truncate(time.Second), window.dataPoints[9].Timestamp) + }) + + t.Run("adds a new DataPoint with a clockdrift", func(t *testing.T) { + now := time.Now().Add(time.Second * 2) + + window := slidingWindow{ + resolution: time.Second, + length: time.Second * 10, + clockdrift: time.Second * 5, + } + + window.AddCount(now) + + assert.Len(t, window.dataPoints, 10) + assert.Equal(t, uint32(1), window.dataPoints[6].Count) + }) + + t.Run("increases Count of existing DataPoint", func(t *testing.T) { + now := time.Now().Truncate(time.Second) + window := slidingWindow{ + resolution: time.Second, + length: time.Second * 10, + dataPoints: []DataPoint{ + {Timestamp: now, Count: 1}, + }, + } + + window.AddCount(now) + + assert.Len(t, window.dataPoints, 1) + assert.Equal(t, uint32(2), window.dataPoints[0].Count) + }) +} + +func TestSlidingWindow_slide(t *testing.T) { + t.Run("removes dataPoints older than length", func(t *testing.T) { + now := time.Now().Truncate(time.Second) + slightlyOlder := now.Add(-time.Millisecond) + window := slidingWindow{ + resolution: time.Second, + length: time.Second * 10, + dataPoints: []DataPoint{ + {Timestamp: slightlyOlder.Add(time.Second * -10), Count: 1}, + {Timestamp: now.Add(time.Second * -9), Count: 1}, + {Timestamp: now.Add(time.Second * -8), Count: 1}, + }, + } + + window.slide(now) + + assert.Len(t, window.dataPoints, 2) + assert.Equal(t, now.Add(time.Second*-9), window.dataPoints[0].Timestamp) + assert.Equal(t, uint32(1), window.dataPoints[0].Count) + assert.Equal(t, now.Add(time.Second*-8), window.dataPoints[1].Timestamp) + assert.Equal(t, uint32(1), window.dataPoints[1].Count) + }) +} + +func TestSlidingWindow_consolidate(t *testing.T) { + t.Run("it fills up a window to the length", func(t *testing.T) { + window := slidingWindow{ + resolution: time.Second, + length: time.Second * 10, + dataPoints: []DataPoint{}, + } + + window.consolidate() + + assert.Len(t, window.dataPoints, 10) + assert.Equal(t, uint32(0), window.dataPoints[0].Count) + }) + + t.Run("it fills gaps in the window", func(t *testing.T) { + now := time.Now().Truncate(time.Second) + window := slidingWindow{ + resolution: time.Second, + length: time.Second * 5, + dataPoints: []DataPoint{ + {Timestamp: now.Add(time.Second * -4), Count: 1}, + {Timestamp: now.Add(time.Second * -2), Count: 1}, + {Timestamp: now, Count: 2}, + }, + } + + window.consolidate() + + require.Len(t, window.dataPoints, 5) + assert.Equal(t, uint32(1), window.dataPoints[0].Count) + assert.Equal(t, uint32(0), window.dataPoints[1].Count) + assert.Equal(t, uint32(1), window.dataPoints[2].Count) + assert.Equal(t, uint32(0), window.dataPoints[3].Count) + assert.Equal(t, uint32(2), window.dataPoints[4].Count) + }) +} + +func TestSlidingWindow_Start(t *testing.T) { + t.Run("slides & consolidates periodically", func(t *testing.T) { + window := slidingWindow{ + resolution: time.Second, + length: time.Second * 5, + evictionInterval: time.Millisecond, + dataPoints: []DataPoint{ + {Timestamp: time.Now().Truncate(time.Second).Add(-5 * time.Second), Count: 2}, + {Timestamp: time.Now().Truncate(time.Second).Add(-4 * time.Second), Count: 1}, + }, + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + window.Start(ctx) + + time.Sleep(time.Millisecond * 10) + + window.mutex.Lock() + defer window.mutex.Unlock() + + require.Len(t, window.dataPoints, 5) + assert.Equal(t, uint32(1), window.dataPoints[0].Count) + }) +} + +func TestSlidingWindow_toIndex(t *testing.T) { + // 5 second window, 1 second resolution + // add a datapoint for each second starting at -11 * time.Now() + // call toIndex with each datapoint + // assert that the index is correct + now := time.Now().Truncate(time.Second) + window := slidingWindow{ + resolution: time.Second, + length: time.Second * 5, + dataPoints: []DataPoint{ + {Timestamp: now.Add(time.Second * -4), Count: 1}, // -4 to -3 interval + {Timestamp: now.Add(time.Second * -3), Count: 1}, // -3 to -2 interval + {Timestamp: now.Add(time.Second * -2), Count: 1}, // -2 to -1 interval + {Timestamp: now.Add(time.Second * -1), Count: 1}, // -1 to truncated interval + {Timestamp: now, Count: 1}, // truncated at current second + }, + } + + for i := 0; i < 5; i++ { + assert.Equal(t, i, window.toIndex(window.dataPoints[i], now)) + } +} diff --git a/integration_test.go b/integration_test.go index 2f6a9d6..dc75f9b 100644 --- a/integration_test.go +++ b/integration_test.go @@ -28,6 +28,7 @@ import ( "nuts-foundation/nuts-monitor/client" "nuts-foundation/nuts-monitor/client/diagnostics" "nuts-foundation/nuts-monitor/client/network" + "nuts-foundation/nuts-monitor/config" "nuts-foundation/nuts-monitor/test" "os" "testing" @@ -116,7 +117,8 @@ func TestNetworkTopology(t *testing.T) { } func startServer(t *testing.T) int { - e := newEchoServer() + cfg := config.LoadConfig() + e := newEchoServer(cfg, nil) httpPort := test.FreeTCPPort() diff --git a/main.go b/main.go index 45c6b6b..1cc3966 100644 --- a/main.go +++ b/main.go @@ -19,6 +19,7 @@ package main import ( + "context" "embed" "fmt" "io/fs" @@ -27,6 +28,7 @@ import ( "nuts-foundation/nuts-monitor/api" "nuts-foundation/nuts-monitor/client" "nuts-foundation/nuts-monitor/config" + "nuts-foundation/nuts-monitor/data" "os" "path" @@ -40,17 +42,57 @@ const assetPath = "web" var embeddedFiles embed.FS func main() { - e := newEchoServer() + // first load the config + config := config.LoadConfig() + config.Print(log.Writer()) + + // then initialize the data storage and fill it with the initial transactions + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + store := data.NewStore() + loadHistory(store, config) + store.Start(ctx) + + // start the web server + e := newEchoServer(config, store) // Start server e.Logger.Fatal(e.Start(fmt.Sprintf(":%d", 1313))) } -func newEchoServer() *echo.Echo { - // config - config := config.LoadConfig() - config.Print(log.Writer()) +// loadHistory requests transactions from the Nuts node per 100 and stores them in the data store +func loadHistory(store *data.Store, c config.Config) { + // initialize the client + client := client.HTTPClient{ + Config: c, + } + + // load the initial transactions + // ListTransactions per batch of 100, stop if the list is empty + // currentOffset is used to determine the offset for the next batch + currentOffset := 0 + for { + transactions, err := client.ListTransactions(context.Background(), currentOffset, currentOffset+100) + if err != nil { + log.Fatalf("failed to load historic transactions: %s", err) + } + if len(transactions) == 0 { + break + } + // the transactions need to be converted from string to Transaction + for _, stringTransaction := range transactions { + transaction, err := data.FromJWS(stringTransaction) + if err != nil { + log.Printf("failed to parse transaction: %s", err) + } + store.Add(*transaction) + } + // increase offset for next batch + currentOffset += 100 + } +} +func newEchoServer(config config.Config, store *data.Store) *echo.Echo { // http server e := echo.New() e.HideBanner = true @@ -68,6 +110,7 @@ func newEchoServer() *echo.Echo { Client: client.HTTPClient{ Config: config, }, + DataStore: store, } api.RegisterHandlers(e, api.NewStrictHandler(apiWrapper, []api.StrictMiddlewareFunc{})) diff --git a/web/src/admin/AdminApp.vue b/web/src/admin/AdminApp.vue index feb769f..09d3393 100644 --- a/web/src/admin/AdminApp.vue +++ b/web/src/admin/AdminApp.vue @@ -26,7 +26,6 @@
@@ -49,7 +48,6 @@ Diagnostics @@ -61,6 +59,18 @@ NetworkTopology + +
+ + + +
+ + Transactions +
diff --git a/web/src/admin/Transactions.vue b/web/src/admin/Transactions.vue new file mode 100644 index 0000000..fa4ce4c --- /dev/null +++ b/web/src/admin/Transactions.vue @@ -0,0 +1,161 @@ + + + diff --git a/web/src/index.js b/web/src/index.js index ff650d1..6b4697a 100644 --- a/web/src/index.js +++ b/web/src/index.js @@ -7,6 +7,7 @@ import AdminApp from './admin/AdminApp.vue' import Diagnostics from './admin/Diagnostics.vue' import Logout from './Logout.vue' import NetworkTopology from './admin/NetworkTopology.vue' +import Transactions from './admin/Transactions.vue' import NotFound from './NotFound.vue' // Vuetify @@ -41,6 +42,11 @@ const routes = [ path: 'network_topology', name: 'admin.network_topology', component: NetworkTopology + }, + { + path: 'transactions', + name: 'admin.transactions', + component: Transactions } ], //meta: { requiresAuth: true }