From 54acbf2fbf16792ddde09cb10da32233a15fb244 Mon Sep 17 00:00:00 2001 From: marle3003 Date: Tue, 13 Oct 2026 18:35:07 +0200 Subject: [PATCH 01/49] fix(openapi): don't join query parameter values when using style deepObject --- providers/openapi/parameter_query.go | 19 ++++++++++---- providers/openapi/parameter_query_test.go | 32 +++++++++++++++++++++++ 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/providers/openapi/parameter_query.go b/providers/openapi/parameter_query.go index 680a59b5a..0a78b1c3a 100644 --- a/providers/openapi/parameter_query.go +++ b/providers/openapi/parameter_query.go @@ -46,7 +46,7 @@ func parseQuery(param *Parameter, u *url.URL) (*RequestParameterValue, error) { } } -func parseQueryObject(param *Parameter, u *url.URL) (string, interface{}, error) { +func parseQueryObject(param *Parameter, u *url.URL) (string, any, error) { if param.Style == "form" && param.IsExplode() { raw := u.RawQuery if len(raw) == 0 { @@ -69,7 +69,7 @@ func parseQueryObject(param *Parameter, u *url.URL) (string, interface{}, error) return raw, i, err } else if param.Style == "deepObject" && param.IsExplode() { paramRegex := regexp.MustCompile(fmt.Sprintf(`^%s\[(?P.+)\]$`, regexp.QuoteMeta(param.Name))) - obj := map[string]interface{}{} + obj := map[string]any{} raw := strings.Builder{} for k, values := range u.Query() { @@ -82,9 +82,18 @@ func parseQueryObject(param *Parameter, u *url.URL) (string, interface{}, error) if prop == nil && !param.Schema.IsFreeForm() && !param.Schema.IsDictionary() { return "", nil, fmt.Errorf("property '%v' not defined in schema: %s", name, param.Schema) } - s := strings.Join(values, ",") - raw.WriteString(fmt.Sprintf("%v[%v]=%v", param.Name, name, s)) - if v, err := p.ParseWith(s, schema.ConvertToJsonSchema(prop)); err != nil { + + var data any + if prop != nil && prop.Type.IsArray() { + data = values + } else if len(values) == 1 { + data = values[0] + } else { + data = values + } + + raw.WriteString(fmt.Sprintf("%v[%v]=%v", param.Name, name, strings.Join(values, ","))) + if v, err := p.ParseWith(data, schema.ConvertToJsonSchema(prop)); err != nil { return "", nil, err } else { obj[name] = v diff --git a/providers/openapi/parameter_query_test.go b/providers/openapi/parameter_query_test.go index 3c3f4d624..57b0a2d23 100644 --- a/providers/openapi/parameter_query_test.go +++ b/providers/openapi/parameter_query_test.go @@ -649,6 +649,38 @@ func TestParseQueryDeepObjectOtherParameters(t *testing.T) { query: url.Values{"filter[unknown]": {"guest"}}, wantErr: "parse query parameter 'filter' failed: property 'unknown' not defined in schema: schema type=object properties=[role] free-form=false", }, + { + name: "property is array using multiple values", + params: openapi.Parameters{{Value: &openapi.Parameter{ + Name: "filter", + Type: openapi.ParameterQuery, + Schema: schematest.New("object", + schematest.WithProperty("role", schematest.New("array", schematest.WithItems("string"))), + ), + Style: "deepObject", + Explode: new(true), + }}}, + query: url.Values{ + "filter[role]": {"admin", "user"}, + }, + want: map[string]any{"filter": map[string]any{"role": []any{"admin", "user"}}}, + }, + { + name: "property is array using comma separated values", + params: openapi.Parameters{{Value: &openapi.Parameter{ + Name: "filter", + Type: openapi.ParameterQuery, + Schema: schematest.New("object", + schematest.WithProperty("role", schematest.New("array", schematest.WithItems("string"))), + ), + Style: "deepObject", + Explode: new(true), + }}}, + query: url.Values{ + "filter[role]": {"admin,user"}, + }, + want: map[string]any{"filter": map[string]any{"role": []any{"admin,user"}}}, + }, } for _, tc := range testcases { From 047c0ed938a54712a72e1618519e72017e940ad9 Mon Sep 17 00:00:00 2001 From: marle3003 Date: Tue, 13 Oct 2026 18:40:13 +0200 Subject: [PATCH 02/49] fix(javascript): improve function signature for operationId http event handler registration --- js/mokapi/app_http.go | 16 +++++++++--- js/mokapi/app_http_test.go | 11 ++++++++ npm/go-mokapi/types/index.d.ts | 47 +++++++++++++++++----------------- 3 files changed, 48 insertions(+), 26 deletions(-) diff --git a/js/mokapi/app_http.go b/js/mokapi/app_http.go index ddffe1abd..7fba99ba1 100644 --- a/js/mokapi/app_http.go +++ b/js/mokapi/app_http.go @@ -25,11 +25,19 @@ func (h *Http) Route(path string) goja.Value { return newRouteObject(r) } -func (h *Http) Operation(operationId string) goja.Value { +func (h *Http) Operation(operationId string, do goja.Value, vArgs goja.Value) goja.Value { filter := h.filter filter.OperationId = operationId - r := &HttpRoute{filter: filter, m: h.m} - return newRouteObject(r) + + args, err := getOnArgs(h.m.vm, vArgs) + if err != nil { + panic(err) + } + f := getHandler(do, args, h.m.vm, h.m.loop) + + h.m.host.OnHttp(filter, f, common.EventArgs{Tags: args.tags, Priority: args.priority}) + + return newHttpObject(h) } func (h *Http) Method(method string, path string, do goja.Value, vArgs goja.Value) goja.Value { @@ -110,6 +118,8 @@ func (r *HttpObject) Get(key string) goja.Value { return r.vm.ToValue(r.http.Use) case "route": return r.vm.ToValue(r.http.Route) + case "operation": + return r.vm.ToValue(r.http.Operation) case "api": return r.vm.ToValue(r.http.Api) } diff --git a/js/mokapi/app_http_test.go b/js/mokapi/app_http_test.go index 04ed57302..443e6de92 100644 --- a/js/mokapi/app_http_test.go +++ b/js/mokapi/app_http_test.go @@ -168,6 +168,17 @@ m.app.http().route('/pets').use((req, res) => {}).get((req, res) => {}) r.Equal(t, "/pets", handlers[1].Filter.Path) }, }, + { + name: "operationId", + script: ` +const m = require('mokapi') +m.app.http().operation('foo', (req, res) => {}) +`, + test: func(t *testing.T, handlers []handler, err error) { + r.Len(t, handlers, 1) + r.Equal(t, "foo", handlers[0].Filter.OperationId) + }, + }, } for _, tc := range testcases { diff --git a/npm/go-mokapi/types/index.d.ts b/npm/go-mokapi/types/index.d.ts index 63713af37..702b7984b 100644 --- a/npm/go-mokapi/types/index.d.ts +++ b/npm/go-mokapi/types/index.d.ts @@ -9,8 +9,6 @@ * https://mokapi.io/docs/javascript-api/overview */ -import {ProduceRetry} from "./mqtt"; - /** * Attaches an event handler for the given event. * @@ -1218,18 +1216,6 @@ export interface ScheduledEventArgs { runFirstTimeImmediately?: boolean; } -/** - * JavaScript value representable with JSON. - */ -export type JSONValue = null | undefined | boolean | number | string | JSONValue[] | JSONObject; - -/** - * Object representable with JSON. - */ -export interface JSONObject { - [key: string]: JSONValue; -} - /** * Specifies the date-time format defined in [RFC 3339](https://datatracker.ietf.org/doc/html/rfc3339). * This constant can be used when defining or validating datetime strings. @@ -1756,24 +1742,39 @@ export interface HttpRouter { route(path: string): HttpRoute /** - * Returns an `HttpRoute` matched by the given `operationId` as defined - * in the OpenAPI specification. + * Registers a handler directly for the operation identified by the given + * `operationId` as defined in the OpenAPI specification. + * + * Since an `operationId` already uniquely identifies both the path and the + * HTTP method of an operation, no further method scoping is needed. + * This makes it a concise alternative to `route(path).get(handler)` — + * especially useful when paths are long or contain many parameters. * - * Use this as an alternative to `route()` when you prefer to reference - * operations by name rather than by path template. + * Behaves identically to the method shorthand functions such as `get()` and + * `post()` on `HttpRouter`: all registered handlers run in order, and + * `res.stopPropagation()` can be used to prevent subsequent handlers + * from running. * - * @param operationId The `operationId` defined in the OpenAPI operation. - * @returns An `HttpRoute` for the matching operation. + * @param operationId The `operationId` as defined in the OpenAPI operation. + * @param handler The event handler to invoke. + * @param opts Optional handler configuration such as priority and tracking. + * @returns This `HttpRouter` instance for chaining. * * @example + * // Equivalent to: app.api('Petstore').http().route('/pets').get(handler) * import { app } from 'mokapi' * export default function() { * app.api('Petstore').http() - * .operation('listPets') - * .get((req, res) => { res.data = [] }) + * .operation('listPets', (req, res) => { + * res.data = [{ id: 1, name: 'Bello' }] + * }) + * .operation('createPet', (req, res) => { + * res.statusCode = 201 + * res.data = { id: 2, name: req.body.name } + * }) * } */ - operation(operationId: string): HttpRoute + operation(operationId: string, handler: HttpEventHandler, opts?: HttpEventArgs): HttpRouter /** * Narrows this router to a specific API by title. From e6f29409515bd5a294a0b10b4f42076c9ef7228d Mon Sep 17 00:00:00 2001 From: marle3003 Date: Tue, 13 Oct 2026 18:42:22 +0200 Subject: [PATCH 03/49] refactor(typescript): replace JSONValue with any to simplify usage --- npm/go-mokapi/types/http.d.ts | 14 ++++++-------- npm/go-mokapi/types/kafka.d.ts | 14 ++++++-------- npm/go-mokapi/types/mustache.d.ts | 4 +--- npm/go-mokapi/types/yaml.d.ts | 4 +--- 4 files changed, 14 insertions(+), 22 deletions(-) diff --git a/npm/go-mokapi/types/http.d.ts b/npm/go-mokapi/types/http.d.ts index b6d17a940..5d7ee22ae 100644 --- a/npm/go-mokapi/types/http.d.ts +++ b/npm/go-mokapi/types/http.d.ts @@ -1,5 +1,3 @@ -import { JSONValue } from "."; - /** * Make GET request. * https://mokapi.io/docs/javascript-api/mokapi-http/get @@ -23,7 +21,7 @@ export function get(url: string, args?: Args): Response; * headers: { 'Content-Type': "application/json" } * }) */ -export function post(url: string, body?: JSONValue, args?: Args): Response; +export function post(url: string, body?: any, args?: Args): Response; /** * Make PUT request. @@ -37,7 +35,7 @@ export function post(url: string, body?: JSONValue, args?: Args): Response; * headers: { 'Content-Type': "application/json" } * }) */ -export function put(url: string, body?: JSONValue, args?: Args): Response; +export function put(url: string, body?: any, args?: Args): Response; /** * Make HEAD request. @@ -60,7 +58,7 @@ export function head(url: string, args?: Args): Response; * @example * patch("https://foo.bar/foo") */ -export function patch(url: string, body?: JSONValue, args?: Args): Response; +export function patch(url: string, body?: any, args?: Args): Response; /** * Make PATCH request. @@ -72,7 +70,7 @@ export function patch(url: string, body?: JSONValue, args?: Args): Response; * @example * del("https://foo.bar/foo") */ -export function del(url: string, body?: JSONValue, args?: Args): Response; +export function del(url: string, body?: any, args?: Args): Response; /** * Make OPTIONS request. @@ -84,7 +82,7 @@ export function del(url: string, body?: JSONValue, args?: Args): Response; * @example * options("https://foo.bar/foo") */ -export function options(url: string, body?: JSONValue, args?: Args): Response; +export function options(url: string, body?: any, args?: Args): Response; /** * Sends an HTTP request and returns a Promise resolving to the response. @@ -224,5 +222,5 @@ export interface Response { * const res = get(url) * res.json() */ - json(): JSONValue; + json(): any; } diff --git a/npm/go-mokapi/types/kafka.d.ts b/npm/go-mokapi/types/kafka.d.ts index 4f1285f75..efd4ce6b4 100644 --- a/npm/go-mokapi/types/kafka.d.ts +++ b/npm/go-mokapi/types/kafka.d.ts @@ -1,5 +1,3 @@ -import { JSONValue } from "."; - /** * Sends messages to a Kafka topic. * https://mokapi.io/docs/javascript-api/mokapi-kafka/produce @@ -73,13 +71,13 @@ export interface ProduceArgs { partition?: number; /** Kafka message key. If not specified, a random key will be generated based on the topic configuration. */ - key?: JSONValue; + key?: any; /** Kafka message value. If not specified, a random value will be generated based on the topic configuration. */ - value?: JSONValue; + value?: any; /** Kafka message headers. */ - headers?: { [name: string]: JSONValue }; + headers?: { [name: string]: any }; /** * An array of Kafka messages @@ -117,16 +115,16 @@ export interface Message { partition?: number; /** Kafka message key. If not specified, a random key will be generated based on the topic configuration. */ - key?: JSONValue; + key?: any; /** Kafka message value. If data and value are not specified, a random value will be generated based on the topic configuration. */ - data?: JSONValue; + data?: any; /** Kafka message value not validating against schema. If data and value are not specified, a random value will be generated based on the topic configuration. */ value?: string | number | boolean | null; /** Kafka message headers. */ - headers?: { [name: string]: JSONValue }; + headers?: { [name: string]: any }; } /** diff --git a/npm/go-mokapi/types/mustache.d.ts b/npm/go-mokapi/types/mustache.d.ts index d7be4be1b..47f6fe3dc 100644 --- a/npm/go-mokapi/types/mustache.d.ts +++ b/npm/go-mokapi/types/mustache.d.ts @@ -1,5 +1,3 @@ -import { JSONValue } from "."; - /** * Renders the given mustache template with the given data. * https://mokapi.io/docs/javascript-api/mokapi-mustache/render @@ -22,7 +20,7 @@ import { JSONValue } from "."; */ export function render(template: string, data: Scope): string; -export type Scope = null | Scope[] | ScopeObject | JSONValue; +export type Scope = null | Scope[] | ScopeObject | any; /** * Object representable with JSON. diff --git a/npm/go-mokapi/types/yaml.d.ts b/npm/go-mokapi/types/yaml.d.ts index 267a2e7b1..3a4151156 100644 --- a/npm/go-mokapi/types/yaml.d.ts +++ b/npm/go-mokapi/types/yaml.d.ts @@ -1,5 +1,3 @@ -import { JSONValue } from "."; - /** * Parses a YAML string, constructing the JavaScript value or object described by the string. * https://mokapi.io/docs/javascript-api/mokapi-yaml/parse @@ -35,4 +33,4 @@ export function parse(text: string): any; * }) * } */ -export function stringify(value: JSONValue): string; +export function stringify(value: any): string; From 623c34a778c5525cd60959105a0aa251796a0828 Mon Sep 17 00:00:00 2001 From: marle3003 Date: Tue, 13 Oct 2026 18:44:21 +0200 Subject: [PATCH 04/49] doc: add title to examples --- docs/ldap/quick-start.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/ldap/quick-start.md b/docs/ldap/quick-start.md index 471d47184..b7ee60295 100644 --- a/docs/ldap/quick-start.md +++ b/docs/ldap/quick-start.md @@ -25,7 +25,9 @@ than interacting with actual LDAP entries. You can later extend this configurati advanced server settings as needed. ```yaml tab=ldap.yaml -ldap: 1.0 # file configuration version not LDAP protocol version +ldap: 1.0 # file configuration version not LDAP protocol version +info: + title: Basic LDAP Server host: :389 ``` @@ -81,6 +83,8 @@ In the LDAP configuration, you would reference this LDIF file as follows: ```yaml tab=ldap.yaml ldap: 1.0 # file configuration version not LDAP protocol version +info: + title: LDAP Server using LDIF host: :389 files: - ./example.ldif From 5419f7787fc13ac397f591d1b3f8cea25d2e01d7 Mon Sep 17 00:00:00 2001 From: marle3003 Date: Tue, 13 Oct 2026 22:02:34 +0200 Subject: [PATCH 05/49] feat(api): change events endpoint to paged data source and reduce payload feat(webui): add paging feature for events to dashboard --- acceptance/kafka_test.go | 14 + acceptance/ldap/ldap.yml | 2 +- acceptance/ldap_test.go | 2 +- api/handler.go | 3 +- api/handler_events.go | 370 ++++++++++- api/handler_events_test.go | 612 ++++++++++++++++-- engine/common/host.go | 6 +- engine/host.go | 19 +- mcp/run_events_test.go | 4 +- providers/asyncapi3/kafka/store/log.go | 107 ++- providers/asyncapi3/kafka/store/log_test.go | 4 +- providers/asyncapi3/kafka/store/validation.go | 30 +- .../asyncapi3/kafka/store/validation_test.go | 12 +- providers/asyncapi3/mqtt/store/log.go | 30 +- providers/asyncapi3/mqtt/store/publish.go | 2 +- providers/asyncapi3/websocket/log.go | 12 + providers/directory/log.go | 115 +++- providers/mail/log.go | 18 + providers/openapi/handler_test.go | 27 +- providers/openapi/log.go | 16 + runtime/events/events.go | 1 + runtime/events/eventstest/event.go | 6 +- runtime/index.go | 3 + runtime/logs.go | 4 + webui/e2e/mocks/common.yml | 58 -- webui/e2e/mocks/dashboard.ts | 276 ++++++++ webui/e2e/mocks/dashboard.yml | 38 +- webui/e2e/mocks/events.yaml | 125 ++++ webui/e2e/mocks/helpers.ts | 28 + webui/e2e/mocks/{services_http.js => http.ts} | 121 +++- webui/e2e/mocks/http_handler.js | 575 +--------------- webui/e2e/mocks/jobs.ts | 78 +++ webui/e2e/mocks/kafka.ts | 220 ++++++- webui/e2e/mocks/kafka.yml | 37 ++ webui/e2e/mocks/{ldap.js => ldap.ts} | 0 webui/e2e/mocks/{mail.js => mail.ts} | 11 +- webui/e2e/mocks/metrics.ts | 10 +- webui/e2e/mocks/{mqtt.js => mqtt.ts} | 14 +- webui/e2e/mocks/tsconfig.json | 10 + webui/e2e/mocks/types.d.ts | 36 ++ .../e2e/mocks/{websocket.js => websocket.ts} | 33 +- webui/e2e/tests/components/dashboard.ts | 1 + webui/e2e/tests/dashboard/http/filter.spec.ts | 162 +++++ webui/e2e/tests/dashboard/jobs.spec.ts | 16 + .../dashboard/websocket/overview.spec.ts | 17 +- webui/lib/events.ts | 164 +++++ .../dashboard-demo/collect-dashboard.ts | 11 +- .../dashboard-demo/demo-configs/tsconfig.json | 10 + .../dashboard-demo/demo-configs/websocket.ts | 10 +- webui/src/assets/datatable.css | 8 + webui/src/components/dashboard/Events.vue | 85 --- webui/src/components/dashboard/JobCard.vue | 37 +- webui/src/components/dashboard/Pager.vue | 61 ++ webui/src/components/dashboard/Search.vue | 45 +- webui/src/components/dashboard/Tabs.vue | 1 - .../src/components/dashboard/http/Request.vue | 78 +-- .../components/dashboard/http/Requests.vue | 484 +++++--------- .../dashboard/kafka/KafkaMessages.vue | 388 +++-------- .../dashboard/kafka/KafkaRequests.vue | 110 ++-- .../components/dashboard/kafka/Message.vue | 69 +- .../src/components/dashboard/ldap/Request.vue | 67 +- .../components/dashboard/ldap/Requests.vue | 95 ++- webui/src/components/dashboard/mail/Mail.vue | 63 +- webui/src/components/dashboard/mail/Mails.vue | 83 ++- .../src/components/dashboard/mqtt/Message.vue | 64 +- .../dashboard/mqtt/MqttMessages.vue | 360 +++-------- .../components/dashboard/mqtt/MqttRequest.vue | 40 +- .../dashboard/mqtt/MqttRequests.vue | 122 ++-- .../dashboard/websocket/Message.vue | 60 +- .../dashboard/websocket/WebsocketClients.vue | 16 +- .../dashboard/websocket/WebsocketEvent.vue | 43 +- .../dashboard/websocket/WebsocketEvents.vue | 125 ++-- .../dashboard/websocket/WebsocketMessages.vue | 289 +++------ webui/src/composables/dashboard.backend.ts | 55 +- webui/src/composables/dashboard.demo.ts | 36 +- webui/src/composables/ldap.ts | 38 -- webui/src/composables/meta.ts | 3 +- webui/src/router/index.ts | 93 ++- webui/src/types/dashboard.d.ts | 26 +- webui/src/types/http.d.ts | 7 + webui/src/types/kafka.d.ts | 8 + webui/src/types/ldap.d.ts | 8 + webui/src/types/mail.d.ts | 8 + webui/src/types/mqtt.d.ts | 7 + webui/src/types/service.d.ts | 14 + webui/src/views/DashboardView.vue | 5 - 86 files changed, 3800 insertions(+), 2781 deletions(-) create mode 100644 webui/e2e/mocks/dashboard.ts create mode 100644 webui/e2e/mocks/events.yaml create mode 100644 webui/e2e/mocks/helpers.ts rename webui/e2e/mocks/{services_http.js => http.ts} (85%) create mode 100644 webui/e2e/mocks/jobs.ts rename webui/e2e/mocks/{ldap.js => ldap.ts} (100%) rename webui/e2e/mocks/{mail.js => mail.ts} (94%) rename webui/e2e/mocks/{mqtt.js => mqtt.ts} (94%) create mode 100644 webui/e2e/mocks/tsconfig.json create mode 100644 webui/e2e/mocks/types.d.ts rename webui/e2e/mocks/{websocket.js => websocket.ts} (88%) create mode 100644 webui/e2e/tests/dashboard/http/filter.spec.ts create mode 100644 webui/e2e/tests/dashboard/jobs.spec.ts create mode 100644 webui/lib/events.ts create mode 100644 webui/scripts/dashboard-demo/demo-configs/tsconfig.json delete mode 100644 webui/src/components/dashboard/Events.vue create mode 100644 webui/src/components/dashboard/Pager.vue delete mode 100644 webui/src/composables/ldap.ts diff --git a/acceptance/kafka_test.go b/acceptance/kafka_test.go index f30c601e8..46b9af0d8 100644 --- a/acceptance/kafka_test.go +++ b/acceptance/kafka_test.go @@ -1,7 +1,9 @@ package acceptance import ( + "encoding/json" "fmt" + "mokapi/api" "mokapi/config/static" "mokapi/kafka" "mokapi/kafka/fetch" @@ -154,8 +156,20 @@ func (suite *KafkaSuite) TestKafkaEventAndMetrics() { try.BodyContains(`kafka_messages_total{service=\"A sample AsyncApi Kafka streaming api\",topic=\"petstore.order-event\"}","value":1}`), ) + var eventId string // test kafka events, header added by JavaScript event handler try.GetRequest(suite.T(), fmt.Sprintf("http://127.0.0.1:%d/api/events?namespace=kafka", suite.cfg.Api.Port), nil, + try.AssertBody(func(t *testing.T, body string) { + var data api.EventPage + err := json.Unmarshal([]byte(body), &data) + require.NoError(t, err) + require.Len(t, data.Items, 2) + // first event does not define a header specification therefor header only contains binary + eventId = data.Items[1].Id + }), + ) + + try.GetRequest(suite.T(), fmt.Sprintf("http://127.0.0.1:%d/api/events/"+eventId, suite.cfg.Api.Port), nil, try.BodyContains(`"headers":{"foo":{"value":"bar","binary":"YmFy"}`), try.BodyContains(`"messageId":"order"`), ) diff --git a/acceptance/ldap/ldap.yml b/acceptance/ldap/ldap.yml index 961619fad..a79dafd84 100644 --- a/acceptance/ldap/ldap.yml +++ b/acceptance/ldap/ldap.yml @@ -1,6 +1,6 @@ ldap: 1.0.0 info: - name: Futurama + title: Futurama server: address: "0.0.0.0:8389" rootDomainNamingContext: DC=mokapi,DC=io diff --git a/acceptance/ldap_test.go b/acceptance/ldap_test.go index e61a007cc..c0642e5e7 100644 --- a/acceptance/ldap_test.go +++ b/acceptance/ldap_test.go @@ -69,7 +69,7 @@ func (suite *LdapSuite) TestLog() { try.GetRequest(suite.T(), fmt.Sprintf("http://127.0.0.1:%v/api/events?namespace=ldap", suite.cfg.Api.Port), nil, try.HasStatusCode(http.StatusOK), - try.BodyContains(`:{"request":{"operation":"Search","baseDN":"","scope":"WholeSubtree","dereferencePolicy":0,"sizeLimit":0,"timeLimit":0,"typesOnly":false,"filter":"(objectClass=user)","attributes":["mail"],"controls":null}`), + try.BodyContains(`"criteria":"(objectClass=user)"`), ) } diff --git a/api/handler.go b/api/handler.go index 0b10f1be0..4dc0b2628 100644 --- a/api/handler.go +++ b/api/handler.go @@ -111,6 +111,7 @@ func New(app *runtime.App, config static.Api) Handler { h.setupMqtt() h.setupWebsocket() h.setupFaker() + h.setupEvents() return h } @@ -163,8 +164,6 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.getDashboard(w, r) case strings.HasPrefix(p, "/api/metrics"): h.getMetrics(w, r) - case strings.HasPrefix(p, "/api/events"): - h.getEvents(w, r) case p == "/api/schema/example": h.getExampleData(w, r) case p == "/api/schema/validate": diff --git a/api/handler_events.go b/api/handler_events.go index 9085e4d3f..e380bd81a 100644 --- a/api/handler_events.go +++ b/api/handler_events.go @@ -1,32 +1,370 @@ package api import ( + "fmt" + "mokapi/providers/openapi" "mokapi/runtime/events" "net/http" + "net/netip" + "net/url" + "regexp" + "strconv" "strings" + "time" + + "github.com/gorilla/mux" ) +type httpFilter = func(d *openapi.HttpLog) bool + +type EventInfo struct { + Id string `json:"id"` + Traits events.Traits `json:"traits"` + Time time.Time `json:"time"` + Data any `json:"data,omitempty"` +} + +type EventPage struct { + Items []EventInfo `json:"items"` + Page int `json:"page"` + Size int `json:"size"` + Total int `json:"total"` +} + +type eventQuery struct { + traits events.Traits + http map[string][]string + page int + size int +} + +func (h *handler) setupEvents() { + r := h.router.PathPrefix("/api/events").Subrouter() + + r.HandleFunc("", h.getEvents).Methods(http.MethodGet) + r.HandleFunc("/{id}", h.getEvent).Methods(http.MethodGet) +} + func (h *handler) getEvents(w http.ResponseWriter, r *http.Request) { - var data interface{} + q, err := parseEventFilter(r.URL.Query()) + if err != nil { + writeError(w, err, http.StatusBadRequest) + return + } + + result := EventPage{Page: q.page, Size: q.size} + + data := h.app.Events.GetEvents(q.traits) + data, err = filterEvents(data, q) + if err != nil { + writeError(w, err, http.StatusBadRequest) + return + } + + result.Total = len(data) + start, end := paginate(q.page, q.size, len(data)) + + result.Items = make([]EventInfo, 0, min(q.size, len(data))) + for _, e := range data[start:end] { + result.Items = append(result.Items, eventInfo(e)) + } - segments := strings.Split(r.URL.Path, "/") - if len(segments) == 3 { - traits := events.NewTraits() - for k := range r.URL.Query() { - traits.With(k, r.URL.Query().Get(k)) + w.Header().Set("Content-Type", "application/json") + writeJsonBody(w, result) +} + +func (h *handler) getEvent(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + + e := h.app.Events.GetEvent(vars["id"]) + if !e.IsValid() { + w.WriteHeader(404) + return + } + + w.Header().Set("Content-Type", "application/json") + writeJsonBody(w, e) +} + +func paginate(page int, size int, len int) (int, int) { + start := min((page-1)*size, len) + return start, min(start+size, len) +} + +func filterEvents(data []events.Event, q eventQuery) ([]events.Event, error) { + var result []events.Event + + filterHttp, err := matchHttpEvent(q) + if err != nil { + return nil, err + } + + for _, e := range data { + switch d := e.Data.(type) { + case *openapi.HttpLog: + if filterHttp == nil || filterHttp(d) { + result = append(result, e) + } + default: + result = append(result, e) } + } + return result, nil +} + +func matchHttpEvent(q eventQuery) (httpFilter, error) { + if q.http == nil { + return nil, nil + } - data = h.app.Events.GetEvents(traits) - } else { - e := h.app.Events.GetEvent(segments[3]) - if e.IsValid() { - data = e - } else { - w.WriteHeader(404) - return + var filters []func(d *openapi.HttpLog) bool + for k, v := range q.http { + switch k { + case "method": + filters = append(filters, func(d *openapi.HttpLog) bool { + match := false + for _, m := range v { + if strings.EqualFold(d.Request.Method, m) { + match = true + } + } + return match + }) + case "url": + if v[0] != "" { + regex, err := regexp.Compile(v[0]) + if err != nil { + return nil, fmt.Errorf("invalid url regex: %w", err) + } + filters = append(filters, func(d *openapi.HttpLog) bool { + return regex.MatchString(d.Request.Url) + }) + } + case "requestHeader": + return func(d *openapi.HttpLog) bool { + for _, val := range v { + kv := strings.SplitN(val, ":", 2) + match := false + for _, p := range d.Request.Parameters { + if p.Type != string(openapi.ParameterHeader) { + continue + } + if p.Name == kv[0] { + if len(kv) > 1 { + if p.Raw != nil && kv[1] == *p.Raw { + match = true + break + } + } + } + } + if !match { + return false + } + } + return true + }, nil + case "responseHeader": + return func(d *openapi.HttpLog) bool { + for _, val := range v { + kv := strings.SplitN(val, ":", 2) + hv, ok := d.Response.Headers[kv[0]] + if len(kv) == 1 && !ok { + return false + } else if kv[1] != hv { + return false + } + } + return true + }, nil + case "statusCode": + var values []string + for _, val := range v { + values = append(values, strings.Split(val, ",")...) + } + var checks []httpFilter + for i, val := range values { + if val == "" { + continue + } + + not := false + match := false + if val[0] == '!' { + not = true + val = val[1:] + } + + if len(val) == 3 && strings.HasSuffix(val, "xx") { + val = fmt.Sprintf("%c00-%c99", val[0], val[0]) + } + if strings.Contains(val, "-") { + parts := strings.Split(val, "-") + if len(parts) != 2 { + continue + } + minVal, err := strconv.Atoi(parts[0]) + if err != nil { + return nil, fmt.Errorf("invalid min value in '%s'", values[i]) + } + maxValue, err := strconv.Atoi(parts[1]) + if err != nil { + return nil, fmt.Errorf("invalid max value in '%s'", values[i]) + } + checks = append(checks, func(d *openapi.HttpLog) bool { + match = d.Response.StatusCode >= minVal && d.Response.StatusCode <= maxValue + return not != match + }) + } else { + expect, err := strconv.Atoi(val) + if err != nil { + return nil, fmt.Errorf("invalid value in '%s'", values[i]) + } + checks = append(checks, func(d *openapi.HttpLog) bool { + match = d.Response.StatusCode == expect + return not != match + }) + } + + } + + return func(d *openapi.HttpLog) bool { + for _, check := range checks { + if !check(d) { + return false + } + } + return true + }, nil + case "clientIP": + var values []string + for _, val := range v { + values = append(values, strings.Split(val, ",")...) + } + var checks []httpFilter + for i, val := range values { + if val == "" { + continue + } + + not := false + match := false + if val[0] == '!' { + not = true + val = val[1:] + } + + if strings.Contains(val, "/") { + prefix, err := netip.ParsePrefix(val) + if err != nil { + return nil, fmt.Errorf("invalid prefix in '%s'", values[i]) + } + checks = append(checks, func(d *openapi.HttpLog) bool { + ip := netip.MustParseAddr(d.ClientIP) + return not != prefix.Contains(ip) + }) + } else { + checks = append(checks, func(d *openapi.HttpLog) bool { + match = d.ClientIP == val + return not != match + }) + } + + } + + return func(d *openapi.HttpLog) bool { + for _, check := range checks { + if !check(d) { + return false + } + } + return true + }, nil + } + } + if len(filters) == 0 { + return nil, nil + } + return func(d *openapi.HttpLog) bool { + for _, f := range filters { + if b := f(d); !b { + return false + } } + return true + }, nil +} + +func parseEventFilter(v url.Values) (eventQuery, error) { + q := eventQuery{ + page: 1, + size: 25, + traits: events.NewTraits(), } + var err error - w.Header().Set("Content-Type", "application/json") - writeJsonBody(w, data) + s := v.Get("page") + if s != "" { + q.page, err = strconv.Atoi(s) + if err != nil { + return q, fmt.Errorf("invalid query parameter 'page'") + } + } + + s = v.Get("size") + if s != "" { + q.size, err = strconv.Atoi(s) + if err != nil { + return q, fmt.Errorf("invalid query parameter 'size'") + } + } + + q.http = deepObject(v, "http") + + /*if h := deepObject(q, "http"); len(h["method"]) > 0 { + f.HTTPMethod = strings.ToUpper(h["method"][0]) + }*/ + for name, values := range deepObject(v, "traits") { + q.traits[name] = values[0] + } + return q, nil +} + +func deepObject(q url.Values, name string) map[string][]string { + prefix := name + "[" + result := make(map[string][]string) + + for key, values := range q { + if !strings.HasPrefix(key, prefix) || !strings.HasSuffix(key, "]") { + continue + } + prop := key[len(prefix) : len(key)-1] + if prop == "" || strings.ContainsAny(prop, "[]") { + continue + } + values = splitQueryValues(values) + result[prop] = append(result[prop], values...) + } + return result +} + +func splitQueryValues(values []string) []string { + var result []string + for _, v := range values { + for part := range strings.SplitSeq(v, ",") { + if part = strings.TrimSpace(part); part != "" { + result = append(result, part) + } + } + } + return result +} + +func eventInfo(e events.Event) EventInfo { + return EventInfo{ + Id: e.Id, + Traits: e.Traits, + Time: e.Time, + Data: e.Data.Info(), + } } diff --git a/api/handler_events_test.go b/api/handler_events_test.go index a45fb35eb..a0d9fe406 100644 --- a/api/handler_events_test.go +++ b/api/handler_events_test.go @@ -1,12 +1,16 @@ -package api +package api_test import ( "context" "encoding/json" "fmt" + "mokapi/api" "mokapi/config/dynamic/dynamictest" "mokapi/config/static" - "mokapi/providers/asyncapi3/kafka/store" + kafka "mokapi/providers/asyncapi3/kafka/store" + mqtt "mokapi/providers/asyncapi3/mqtt/store" + "mokapi/providers/asyncapi3/websocket" + "mokapi/providers/directory" "mokapi/providers/openapi" "mokapi/runtime" "mokapi/runtime/events" @@ -14,6 +18,7 @@ import ( "mokapi/try" "net/http" "net/http/httptest" + "slices" "testing" "time" @@ -21,6 +26,18 @@ import ( ) func TestHandler_Events(t *testing.T) { + + newHttpEvent := func(t *testing.T, r *http.Request, traits events.Traits, sm *events.StoreManager) events.Event { + ctx, err := openapi.NewLogEventContext(r, false, traits) + require.NoError(t, err) + l, _ := openapi.LogEventFromContext(ctx) + err = sm.Push(l, traits) + require.NoError(t, err) + evts := sm.GetEvents(traits) + // events are LIFO + return evts[0] + } + testcases := []struct { name string fn func(t *testing.T, h http.Handler, sm *events.StoreManager) @@ -36,27 +53,457 @@ func TestHandler_Events(t *testing.T) { h, try.HasStatusCode(200), try.HasHeader("Content-Type", "application/json"), - try.HasBody(`[]`)) + try.HasBody(`{"items":[],"page":1,"size":25,"total":0}`)) + }, + }, + { + name: "paging", + fn: func(t *testing.T, h http.Handler, sm *events.StoreManager) { + sm.SetStore(5, events.NewTraits().WithNamespace("http")) + var ids []string + for i := 1; i <= 5; i++ { + r := httptest.NewRequest(http.MethodGet, fmt.Sprintf("http://localhost/foo%v", i), nil) + evt := newHttpEvent(t, r, events.NewTraits().WithNamespace("http"), sm) + ids = append(ids, evt.Id) + } + + try.Handler(t, + http.MethodGet, + "http://foo.api/api/events?traits[namespace]=http&page=2&size=2", + nil, + "", + h, + try.HasStatusCode(200), + try.HasHeader("Content-Type", "application/json"), + try.AssertBody(func(t *testing.T, body string) { + var data api.EventPage + require.NoError(t, json.Unmarshal([]byte(body), &data)) + require.Equal(t, 2, data.Page) + require.Equal(t, 2, data.Size) + require.Equal(t, 5, data.Total) + require.Len(t, data.Items, 2) + // ids are FIFO, events are LIFO + require.Equal(t, ids[2], data.Items[0].Id) + require.Equal(t, ids[1], data.Items[1].Id) + require.Equal(t, map[string]any{"deprecated": false, "duration": float64(0), "statusCode": float64(0), "url": "http://localhost/foo3"}, data.Items[0].Data) + require.Equal(t, map[string]any{"deprecated": false, "duration": float64(0), "statusCode": float64(0), "url": "http://localhost/foo2"}, data.Items[1].Data) + }), + ) }, }, { - name: "with http events", + name: "filter http events", fn: func(t *testing.T, h http.Handler, sm *events.StoreManager) { sm.SetStore(1, events.NewTraits().WithNamespace("http")) + sm.SetStore(1, events.NewTraits().WithNamespace("kafka")) + err := sm.Push(&eventstest.Event{Name: "foo"}, events.NewTraits().WithNamespace("http")) - event := sm.GetEvents(events.NewTraits())[0] require.NoError(t, err) + err = sm.Push(&eventstest.Event{Name: "foo"}, events.NewTraits().WithNamespace("kafka")) + require.NoError(t, err) + + evt := sm.GetEvents(events.NewTraits().WithNamespace("http"))[0] try.Handler(t, http.MethodGet, - "http://foo.api/api/events?namespace=http", + "http://foo.api/api/events?traits[namespace]=http", nil, "", h, try.HasStatusCode(200), try.HasHeader("Content-Type", "application/json"), - try.HasBody(fmt.Sprintf(`[{"id":"%v","traits":{"namespace":"http"},"data":{"Name":"foo","api":""},"time":"%v"}]`, - event.Id, - event.Time.Format(time.RFC3339Nano)))) + try.AssertBody(func(t *testing.T, body string) { + var data map[string]any + require.NoError(t, json.Unmarshal([]byte(body), &data)) + require.Len(t, data["items"], 1) + require.Equal(t, []any{ + map[string]any{ + "id": evt.Id, + "traits": map[string]any{"namespace": "http"}, + "time": evt.Time.Format(time.RFC3339Nano), + "data": map[string]any{ + "name": "foo", + "api": "", + }, + }, + }, data["items"]) + }), + ) + }, + }, + { + name: "filter http method", + fn: func(t *testing.T, h http.Handler, sm *events.StoreManager) { + sm.SetStore(2, events.NewTraits().WithNamespace("http")) + + r1 := httptest.NewRequest(http.MethodGet, "http://localhost/foo", nil) + r2 := httptest.NewRequest(http.MethodPost, "http://localhost/foo", nil) + evt1 := newHttpEvent(t, r1, events.NewTraits().WithNamespace("http"), sm) + newHttpEvent(t, r2, events.NewTraits().WithNamespace("http"), sm) + + try.Handler(t, + http.MethodGet, + "http://foo.api/api/events?traits[namespace]=http&http[method]=GET", + nil, + "", + h, + try.HasStatusCode(200), + try.HasHeader("Content-Type", "application/json"), + try.AssertBody(func(t *testing.T, body string) { + var data map[string]any + require.NoError(t, json.Unmarshal([]byte(body), &data)) + require.Len(t, data["items"], 1) + require.Equal(t, []any{ + map[string]any{ + "id": evt1.Id, + "traits": map[string]any{"namespace": "http"}, + "time": evt1.Time.Format(time.RFC3339Nano), + "data": map[string]any{"deprecated": false, "duration": float64(0), "statusCode": float64(0), "url": "http://localhost/foo"}, + }, + }, data["items"]) + }), + ) + }, + }, + { + name: "filter http methods", + fn: func(t *testing.T, h http.Handler, sm *events.StoreManager) { + sm.SetStore(2, events.NewTraits().WithNamespace("http")) + + r1 := httptest.NewRequest(http.MethodGet, "http://localhost/foo", nil) + r2 := httptest.NewRequest(http.MethodPost, "http://localhost/foo", nil) + evt1 := newHttpEvent(t, r1, events.NewTraits().WithNamespace("http"), sm) + evt2 := newHttpEvent(t, r2, events.NewTraits().WithNamespace("http"), sm) + + try.Handler(t, + http.MethodGet, + "http://foo.api/api/events?traits[namespace]=http&http[method]=GET,POST", + nil, + "", + h, + try.HasStatusCode(200), + try.HasHeader("Content-Type", "application/json"), + try.AssertBody(func(t *testing.T, body string) { + var data map[string]any + require.NoError(t, json.Unmarshal([]byte(body), &data)) + require.Len(t, data["items"], 2) + require.Equal(t, []any{ + map[string]any{ + "id": evt2.Id, + "traits": map[string]any{"namespace": "http"}, + "time": evt2.Time.Format(time.RFC3339Nano), + "data": map[string]any{"deprecated": false, "duration": float64(0), "statusCode": float64(0), "url": "http://localhost/foo"}, + }, + map[string]any{ + "id": evt1.Id, + "traits": map[string]any{"namespace": "http"}, + "time": evt1.Time.Format(time.RFC3339Nano), + "data": map[string]any{"deprecated": false, "duration": float64(0), "statusCode": float64(0), "url": "http://localhost/foo"}, + }, + }, data["items"]) + }), + ) + }, + }, + { + name: "filter http url", + fn: func(t *testing.T, h http.Handler, sm *events.StoreManager) { + sm.SetStore(2, events.NewTraits().WithNamespace("http")) + + r1 := httptest.NewRequest(http.MethodGet, "http://localhost/foo", nil) + r2 := httptest.NewRequest(http.MethodPost, "http://localhost/bar", nil) + newHttpEvent(t, r1, events.NewTraits().WithNamespace("http"), sm) + evt2 := newHttpEvent(t, r2, events.NewTraits().WithNamespace("http"), sm) + + try.Handler(t, + http.MethodGet, + "http://foo.api/api/events?traits[namespace]=http&http[url]=bar", + nil, + "", + h, + try.HasStatusCode(200), + try.HasHeader("Content-Type", "application/json"), + try.AssertBody(func(t *testing.T, body string) { + var data map[string]any + require.NoError(t, json.Unmarshal([]byte(body), &data)) + require.Len(t, data["items"], 1) + require.Equal(t, []any{ + map[string]any{ + "id": evt2.Id, + "traits": map[string]any{"namespace": "http"}, + "time": evt2.Time.Format(time.RFC3339Nano), + "data": map[string]any{"deprecated": false, "duration": float64(0), "statusCode": float64(0), "url": "http://localhost/bar"}, + }, + }, data["items"]) + }), + ) + }, + }, + { + name: "filter http request header", + fn: func(t *testing.T, h http.Handler, sm *events.StoreManager) { + sm.SetStore(2, events.NewTraits().WithNamespace("http")) + + r1 := httptest.NewRequest(http.MethodGet, "http://localhost/foo", nil) + r2 := httptest.NewRequest(http.MethodPost, "http://localhost/bar", nil) + newHttpEvent(t, r1, events.NewTraits().WithNamespace("http"), sm) + evt2 := newHttpEvent(t, r2, events.NewTraits().WithNamespace("http"), sm) + log := evt2.Data.(*openapi.HttpLog) + log.Request.Parameters = append(log.Request.Parameters, openapi.HttpParameter{ + Name: "role", + Type: "header", + Value: "", + Raw: new("admin"), + }) + + try.Handler(t, + http.MethodGet, + "http://foo.api/api/events?traits[namespace]=http&http[requestHeader]=role:admin", + nil, + "", + h, + try.HasStatusCode(200), + try.HasHeader("Content-Type", "application/json"), + try.AssertBody(func(t *testing.T, body string) { + var data map[string]any + require.NoError(t, json.Unmarshal([]byte(body), &data)) + require.Len(t, data["items"], 1) + require.Equal(t, []any{ + map[string]any{ + "id": evt2.Id, + "traits": map[string]any{"namespace": "http"}, + "time": evt2.Time.Format(time.RFC3339Nano), + "data": map[string]any{"deprecated": false, "duration": float64(0), "statusCode": float64(0), "url": "http://localhost/bar"}, + }, + }, data["items"]) + }), + ) + }, + }, + { + name: "filter http status code", + fn: func(t *testing.T, h http.Handler, sm *events.StoreManager) { + sm.SetStore(2, events.NewTraits().WithNamespace("http")) + + data := []struct { + url string + method string + status int + }{ + { + url: "http://localhost/foo", + method: http.MethodGet, + status: 200, + }, + { + url: "http://localhost/foo", + method: http.MethodGet, + status: 201, + }, + { + url: "http://localhost/foo", + method: http.MethodGet, + status: 301, + }, + } + + for _, d := range data { + r := httptest.NewRequest(d.method, d.url, nil) + evt := newHttpEvent(t, r, events.NewTraits().WithNamespace("http"), sm) + log := evt.Data.(*openapi.HttpLog) + log.Response.StatusCode = d.status + } + + getStatusCode := func(index int, items []any) int { + if index >= len(items) { + return -1 + } + m := items[index].(map[string]any) + d := m["data"].(map[string]any) + return int(d["statusCode"].(float64)) + } + + tests := []struct { + url string + check try.ResponseCondition + }{ + { + url: "http://foo.api/api/events?traits[namespace]=http&http[statusCode]=200", + check: try.AssertBody(func(t *testing.T, body string) { + var data map[string]any + require.NoError(t, json.Unmarshal([]byte(body), &data)) + items := data["items"].([]any) + require.Len(t, items, 1) + require.Equal(t, 200, getStatusCode(0, items)) + }), + }, + { + url: "http://foo.api/api/events?traits[namespace]=http&http[statusCode]=200-201", + check: try.AssertBody(func(t *testing.T, body string) { + var data map[string]any + require.NoError(t, json.Unmarshal([]byte(body), &data)) + items := data["items"].([]any) + require.Len(t, items, 2) + require.Equal(t, 201, getStatusCode(0, items)) + require.Equal(t, 200, getStatusCode(1, items)) + }), + }, + { + url: "http://foo.api/api/events?traits[namespace]=http&http[statusCode]=2xx", + check: try.AssertBody(func(t *testing.T, body string) { + var data map[string]any + require.NoError(t, json.Unmarshal([]byte(body), &data)) + items := data["items"].([]any) + require.Len(t, items, 2) + require.Equal(t, 201, getStatusCode(0, items)) + require.Equal(t, 200, getStatusCode(1, items)) + }), + }, + { + url: "http://foo.api/api/events?traits[namespace]=http&http[statusCode]=!200-201", + check: try.AssertBody(func(t *testing.T, body string) { + var data map[string]any + require.NoError(t, json.Unmarshal([]byte(body), &data)) + items := data["items"].([]any) + require.Len(t, items, 1) + require.Equal(t, 301, getStatusCode(0, items)) + }), + }, + } + + for _, d := range tests { + try.Handler(t, + http.MethodGet, + d.url, + nil, + "", + h, + try.HasStatusCode(200), + try.HasHeader("Content-Type", "application/json"), + d.check, + ) + } + + }, + }, + { + name: "filter http response header", + fn: func(t *testing.T, h http.Handler, sm *events.StoreManager) { + sm.SetStore(2, events.NewTraits().WithNamespace("http")) + + r1 := httptest.NewRequest(http.MethodGet, "http://localhost/foo", nil) + r2 := httptest.NewRequest(http.MethodPost, "http://localhost/bar", nil) + newHttpEvent(t, r1, events.NewTraits().WithNamespace("http"), sm) + evt2 := newHttpEvent(t, r2, events.NewTraits().WithNamespace("http"), sm) + log := evt2.Data.(*openapi.HttpLog) + log.Response.Headers = map[string]string{"role": "admin"} + + try.Handler(t, + http.MethodGet, + "http://foo.api/api/events?traits[namespace]=http&http[responseHeader]=role:admin", + nil, + "", + h, + try.HasStatusCode(200), + try.HasHeader("Content-Type", "application/json"), + try.AssertBody(func(t *testing.T, body string) { + var data map[string]any + require.NoError(t, json.Unmarshal([]byte(body), &data)) + require.Len(t, data["items"], 1) + require.Equal(t, []any{ + map[string]any{ + "id": evt2.Id, + "traits": map[string]any{"namespace": "http"}, + "time": evt2.Time.Format(time.RFC3339Nano), + "data": map[string]any{"deprecated": false, "duration": float64(0), "statusCode": float64(0), "url": "http://localhost/bar"}, + }, + }, data["items"]) + }), + ) + }, + }, + { + name: "filter clientIP", + fn: func(t *testing.T, h http.Handler, sm *events.StoreManager) { + sm.SetStore(2, events.NewTraits().WithNamespace("http")) + + data := []struct { + url string + method string + clientIP string + }{ + { + url: "http://localhost/foo1", + method: http.MethodGet, + clientIP: "127.0.0.1", + }, + { + url: "http://localhost/foo2", + method: http.MethodGet, + clientIP: "127.0.0.1", + }, + { + url: "http://localhost/foo3", + method: http.MethodGet, + clientIP: "192.0.1.24", + }, + } + + for _, d := range data { + r := httptest.NewRequest(d.method, d.url, nil) + evt := newHttpEvent(t, r, events.NewTraits().WithNamespace("http"), sm) + log := evt.Data.(*openapi.HttpLog) + log.ClientIP = d.clientIP + } + + getUrl := func(index int, items []any) string { + if index >= len(items) { + return "" + } + m := items[index].(map[string]any) + d := m["data"].(map[string]any) + return d["url"].(string) + } + + tests := []struct { + url string + check try.ResponseCondition + }{ + { + url: "http://foo.api/api/events?traits[namespace]=http&http[clientIP]=127.0.0.1", + check: try.AssertBody(func(t *testing.T, body string) { + var data map[string]any + require.NoError(t, json.Unmarshal([]byte(body), &data)) + items := data["items"].([]any) + require.Len(t, items, 2) + require.Equal(t, "http://localhost/foo2", getUrl(0, items)) + require.Equal(t, "http://localhost/foo1", getUrl(1, items)) + }), + }, + { + url: "http://foo.api/api/events?traits[namespace]=http&http[clientIP]=192.0.1.1/24", + check: try.AssertBody(func(t *testing.T, body string) { + var data map[string]any + require.NoError(t, json.Unmarshal([]byte(body), &data)) + items := data["items"].([]any) + require.Len(t, items, 1) + require.Equal(t, "http://localhost/foo3", getUrl(0, items)) + }), + }, + } + + for _, d := range tests { + try.Handler(t, + http.MethodGet, + d.url, + nil, + "", + h, + try.HasStatusCode(200), + try.HasHeader("Content-Type", "application/json"), + d.check, + ) + } + }, }, { @@ -64,19 +511,20 @@ func TestHandler_Events(t *testing.T) { fn: func(t *testing.T, h http.Handler, sm *events.StoreManager) { sm.SetStore(1, events.NewTraits().WithNamespace("http")) err := sm.Push(&eventstest.Event{Name: "foo"}, events.NewTraits().WithNamespace("http")) - event := sm.GetEvents(events.NewTraits())[0] require.NoError(t, err) + + evt := sm.GetEvents(events.NewTraits())[0] try.Handler(t, http.MethodGet, - "http://foo.api/api/events/"+event.Id, + "http://foo.api/api/events/"+evt.Id, nil, "", h, try.HasStatusCode(200), try.HasHeader("Content-Type", "application/json"), - try.HasBody(fmt.Sprintf(`{"id":"%v","traits":{"namespace":"http"},"data":{"Name":"foo","api":""},"time":"%v"}`, - event.Id, - event.Time.Format(time.RFC3339Nano)))) + try.HasBody(fmt.Sprintf(`{"id":"%v","traits":{"namespace":"http"},"data":{"name":"foo","api":""},"time":"%v"}`, + evt.Id, + evt.Time.Format(time.RFC3339Nano)))) }, }, { @@ -101,26 +549,28 @@ func TestHandler_Events(t *testing.T) { l, _ := openapi.LogEventFromContext(ctx) err = sm.Push(l, traits) require.NoError(t, err) + + evt := sm.GetEvents(events.NewTraits())[0] try.Handler(t, http.MethodGet, - "http://foo.api/api/events?namespace=http", + "http://foo.api/api/events/"+evt.Id, nil, "", h, try.HasStatusCode(200), try.AssertBody(func(t *testing.T, body string) { - var m []map[string]any + var m map[string]any require.NoError(t, json.Unmarshal([]byte(body), &m)) - require.Equal(t, map[string]interface{}{ - "actions": interface{}(nil), + require.Equal(t, map[string]any{ + "actions": any(nil), "api": "", "deprecated": false, "duration": float64(0), "path": "", - "request": map[string]interface{}{ + "request": map[string]any{ "method": "get", - "parameters": []interface{}{ - map[string]interface{}{ + "parameters": []any{ + map[string]any{ "name": "Foo", "raw": "bar", "type": "header", @@ -129,10 +579,10 @@ func TestHandler_Events(t *testing.T) { }, "url": "http://localhost/foo", }, - "response": map[string]interface{}{"body": "", "size": float64(0), "statusCode": float64(0)}, + "response": map[string]any{"body": "", "size": float64(0), "statusCode": float64(0)}, "clientIP": "192.0.2.1", }, - m[0]["data"]) + m["data"]) })) }, }, @@ -158,26 +608,28 @@ func TestHandler_Events(t *testing.T) { l, _ := openapi.LogEventFromContext(ctx) err = sm.Push(l, traits) require.NoError(t, err) + + evt := sm.GetEvents(events.NewTraits())[0] try.Handler(t, http.MethodGet, - "http://foo.api/api/events?namespace=http", + "http://foo.api/api/events/"+evt.Id, nil, "", h, try.HasStatusCode(200), try.AssertBody(func(t *testing.T, body string) { - var m []map[string]any + var m map[string]any require.NoError(t, json.Unmarshal([]byte(body), &m)) - require.Equal(t, map[string]interface{}{ - "actions": interface{}(nil), + require.Equal(t, map[string]any{ + "actions": any(nil), "api": "", "deprecated": false, "duration": float64(0), "path": "", - "request": map[string]interface{}{ + "request": map[string]any{ "method": "get", - "parameters": []interface{}{ - map[string]interface{}{ + "parameters": []any{ + map[string]any{ "name": "Foo", "raw": "bar", "type": "header", @@ -186,15 +638,15 @@ func TestHandler_Events(t *testing.T) { }, "url": "http://localhost/foo", }, - "response": map[string]interface{}{"body": "", "size": float64(0), "statusCode": float64(0)}, + "response": map[string]any{"body": "", "size": float64(0), "statusCode": float64(0)}, "clientIP": "192.0.2.1", }, - m[0]["data"]) + m["data"]) })) }, }, { - name: "http with request parameter", + name: "event not found", fn: func(t *testing.T, h http.Handler, sm *events.StoreManager) { try.Handler(t, http.MethodGet, @@ -205,15 +657,70 @@ func TestHandler_Events(t *testing.T) { try.HasStatusCode(404)) }, }, + { + name: "test all events", + fn: func(t *testing.T, h http.Handler, sm *events.StoreManager) { + sm.SetStore(2, events.NewTraits()) + + newHttpEvent(t, httptest.NewRequest("get", "http://localhost/foo", nil), events.NewTraits().WithNamespace("http"), sm) + err := sm.Push(&kafka.KafkaMessageLog{ + Key: &kafka.LogValue{Value: "foo"}, + Message: &kafka.LogValue{Value: "bar"}, + }, events.NewTraits().WithNamespace("kafka")) + require.NoError(t, err) + err = sm.Push(&mqtt.LogMessage{Message: &mqtt.LogValue{Value: "foo"}}, events.NewTraits().WithNamespace("kafka")) + require.NoError(t, err) + err = sm.Push(&websocket.MessageLog{Message: websocket.LogValue{Value: "foo"}}, events.NewTraits().WithNamespace("kafka")) + require.NoError(t, err) + err = sm.Push(&directory.SearchLog{ + Request: &directory.SearchRequest{Operation: "Search", BaseDN: "foo", Filter: "bar"}, + Response: &directory.SearchResponse{Status: "ok"}, + Duration: int64(10), + }, events.NewTraits().WithNamespace("kafka")) + require.NoError(t, err) + + getItemData := func(items []any, index int) map[string]any { + if index >= len(items) { + t.FailNow() + } + m := items[index].(map[string]any) + d, ok := m["data"] + require.True(t, ok, "data field not found for index %d", index) + return d.(map[string]any) + } + + try.Handler(t, + http.MethodGet, + "http://foo.api/api/events", + nil, + "", + h, + try.HasStatusCode(200), + try.HasHeader("Content-Type", "application/json"), + try.AssertBody(func(t *testing.T, body string) { + var data map[string]any + require.NoError(t, json.Unmarshal([]byte(body), &data)) + require.Len(t, data["items"], 5) + items := data["items"].([]any) + // get the inserted order not LIFO + slices.Reverse(items) + require.Equal(t, map[string]any{"deprecated": false, "duration": float64(0), "statusCode": float64(0), "url": "http://localhost/foo"}, getItemData(items, 0)) + require.Equal(t, map[string]any{"deleted": false, "key": map[string]any{"binary": nil, "value": "foo"}, "message": map[string]any{"binary": nil, "value": "bar"}}, getItemData(items, 1)) + require.Equal(t, map[string]any{"topic": "", "message": map[string]any{"binary": nil, "value": "foo"}}, getItemData(items, 2)) + require.Equal(t, map[string]any{"binary": nil, "value": "foo"}, getItemData(items, 3)["message"]) + require.Equal(t, map[string]any{"criteria": "bar", "dn": "foo", "duration": float64(10), "operation": "Search", "status": "ok"}, getItemData(items, 4)) + }), + ) + }, + }, } for _, tc := range testcases { - tc := tc t.Run(tc.name, func(t *testing.T) { cfg := &static.Config{} app := runtime.New(cfg, &dynamictest.Reader{}) - h := New(app, static.Api{}) + h := api.New(app, static.Api{}) tc.fn(t, h, app.Events) }) } @@ -235,7 +742,7 @@ func TestHandler_KafkaEvents(t *testing.T) { h, try.HasStatusCode(200), try.HasHeader("Content-Type", "application/json"), - try.HasBody(`[]`)) + try.HasBody(`{"items":[],"page":1,"size":25,"total":0}`)) }, }, { @@ -253,7 +760,7 @@ func TestHandler_KafkaEvents(t *testing.T) { h, try.HasStatusCode(200), try.HasHeader("Content-Type", "application/json"), - try.HasBody(fmt.Sprintf(`[{"id":"%v","traits":{"namespace":"kafka"},"data":{"Name":"foo","api":""},"time":"%v"}]`, + try.HasBody(fmt.Sprintf(`{"items":[{"id":"%v","traits":{"namespace":"kafka"},"time":"%v","data":{"name":"foo","api":""}}],"page":1,"size":25,"total":1}`, event.Id, event.Time.Format(time.RFC3339Nano)))) }, @@ -263,8 +770,9 @@ func TestHandler_KafkaEvents(t *testing.T) { fn: func(t *testing.T, h http.Handler, sm *events.StoreManager) { sm.SetStore(1, events.NewTraits().WithNamespace("kafka")) err := sm.Push(&eventstest.Event{Name: "foo"}, events.NewTraits().WithNamespace("kafka")) - event := sm.GetEvents(events.NewTraits())[0] require.NoError(t, err) + + event := sm.GetEvents(events.NewTraits())[0] try.Handler(t, http.MethodGet, "http://foo.api/api/events/"+event.Id, @@ -273,7 +781,7 @@ func TestHandler_KafkaEvents(t *testing.T) { h, try.HasStatusCode(200), try.HasHeader("Content-Type", "application/json"), - try.HasBody(fmt.Sprintf(`{"id":"%v","traits":{"namespace":"kafka"},"data":{"Name":"foo","api":""},"time":"%v"}`, + try.HasBody(fmt.Sprintf(`{"id":"%v","traits":{"namespace":"kafka"},"data":{"name":"foo","api":""},"time":"%v"}`, event.Id, event.Time.Format(time.RFC3339Nano)))) }, @@ -283,10 +791,10 @@ func TestHandler_KafkaEvents(t *testing.T) { fn: func(t *testing.T, h http.Handler, sm *events.StoreManager) { sm.SetStore(1, events.NewTraits().WithNamespace("kafka")) - err := sm.Push(&store.KafkaMessageLog{ + err := sm.Push(&kafka.KafkaMessageLog{ Offset: 123, - Key: store.LogValue{}, - Message: store.LogValue{}, + Key: &kafka.LogValue{}, + Message: &kafka.LogValue{}, MessageId: "foo-1", Partition: 1, ProducerId: 3, @@ -297,26 +805,27 @@ func TestHandler_KafkaEvents(t *testing.T) { }, events.NewTraits().WithNamespace("kafka")) require.NoError(t, err) + event := sm.GetEvents(events.NewTraits())[0] try.Handler(t, http.MethodGet, - "http://foo.api/api/events?namespace=kafka", + "http://foo.api/api/events/"+event.Id, nil, "", h, try.HasStatusCode(200), try.AssertBody(func(t *testing.T, body string) { - var m []map[string]any + var m map[string]any require.NoError(t, json.Unmarshal([]byte(body), &m)) require.Equal(t, map[string]any{ "api": "", "deleted": false, - "headers": interface{}(nil), - "key": map[string]interface{}{ - "binary": interface{}(nil), + "headers": any(nil), + "key": map[string]any{ + "binary": any(nil), "value": "", }, - "message": map[string]interface{}{ - "binary": interface{}(nil), + "message": map[string]any{ + "binary": any(nil), "value": "", }, "messageId": "foo-1", @@ -330,19 +839,18 @@ func TestHandler_KafkaEvents(t *testing.T) { "script": "123-123", "actions": nil, }, - m[0]["data"]) + m["data"]) })) }, }, } for _, tc := range testcases { - tc := tc t.Run(tc.name, func(t *testing.T) { cfg := &static.Config{} app := runtime.New(cfg, &dynamictest.Reader{}) - h := New(app, static.Api{}) + h := api.New(app, static.Api{}) tc.fn(t, h, app.Events) }) } diff --git a/engine/common/host.go b/engine/common/host.go index 2d67ce656..933e70c73 100644 --- a/engine/common/host.go +++ b/engine/common/host.go @@ -243,13 +243,17 @@ func (e *JobExecution) Title() string { return e.Tags["name"] } +func (e *JobExecution) Info() any { + return e +} + func (e *JobExecution) Domain() string { return "Job Execution" } type FakerNode interface { Name() string - Fake(r *generator.Request) (interface{}, error) + Fake(r *generator.Request) (any, error) } type Store interface { diff --git a/engine/host.go b/engine/host.go index 23cf389fb..2e071ab88 100644 --- a/engine/host.go +++ b/engine/host.go @@ -4,6 +4,7 @@ import ( "crypto/tls" "encoding/json" "fmt" + "maps" "mokapi/config/dynamic" "mokapi/config/dynamic/provider/file" "mokapi/engine/common" @@ -112,9 +113,7 @@ func (sh *scriptHost) newJobFunc(handler func(), opt common.JobOptions, schedule "file": sh.name, "fileKey": sh.file.Info.Key(), } - for k, v := range opt.Tags { - tags[k] = v - } + maps.Copy(tags, opt.Tags) t := events.NewTraits().WithNamespace("job").WithName(tags["name"]) if len(sh.sm.GetStores(t)) == 1 { @@ -189,9 +188,7 @@ func (sh *scriptHost) On(event string, handler common.EventHandler, args common. priority: args.Priority, } - for k, v := range args.Tags { - h.tags[k] = v - } + maps.Copy(h.tags, args.Tags) sh.events[event] = append(sh.events[event], h) } @@ -222,22 +219,22 @@ func (sh *scriptHost) AddCleanupFunc(f func()) { sh.cleanupFuncs = append(sh.cleanupFuncs, f) } -func (sh *scriptHost) Info(args ...interface{}) { +func (sh *scriptHost) Info(args ...any) { sh.engine.logger.Info(args...) sh.logEvent("info", "log", args...) } -func (sh *scriptHost) Warn(args ...interface{}) { +func (sh *scriptHost) Warn(args ...any) { sh.engine.logger.Warn(args...) sh.logEvent("warn", "warn", args...) } -func (sh *scriptHost) Error(args ...interface{}) { +func (sh *scriptHost) Error(args ...any) { sh.engine.logger.Error(args...) sh.logEvent("error", "error", args...) } -func (sh *scriptHost) Debug(args ...interface{}) { +func (sh *scriptHost) Debug(args ...any) { sh.engine.logger.Debug(args...) sh.logEvent("debug", "debug", args...) } @@ -248,7 +245,7 @@ func (sh *scriptHost) SetEventLogger(logger func(level, message string)) { sh.eventLogger = logger } -func (sh *scriptHost) logEvent(configuredLevel, eventLevel string, args ...interface{}) { +func (sh *scriptHost) logEvent(configuredLevel, eventLevel string, args ...any) { if !sh.IsLevelEnabled(configuredLevel) { return } diff --git a/mcp/run_events_test.go b/mcp/run_events_test.go index 782d22f5f..b3ae03853 100644 --- a/mcp/run_events_test.go +++ b/mcp/run_events_test.go @@ -73,8 +73,8 @@ func TestEvents(t *testing.T) { app: runtimetest.NewApp( runtimetest.WithEvent(events.NewTraits().WithNamespace("kafka").With("topic", "foo"), &store.KafkaMessageLog{ Offset: 1234, - Key: store.LogValue{Value: "key"}, - Message: store.LogValue{Value: "message"}, + Key: &store.LogValue{Value: "key"}, + Message: &store.LogValue{Value: "message"}, Partition: 8, Api: "cluster", }), diff --git a/providers/asyncapi3/kafka/store/log.go b/providers/asyncapi3/kafka/store/log.go index 8d38c64a0..1c847b3ab 100644 --- a/providers/asyncapi3/kafka/store/log.go +++ b/providers/asyncapi3/kafka/store/log.go @@ -11,21 +11,27 @@ import ( type LogRecord func(log *KafkaMessageLog, traits events.Traits) type KafkaMessageLog struct { - Offset int64 `json:"offset"` - Key LogValue `json:"key"` - Message LogValue `json:"message"` - SchemaId int `json:"schemaId"` - MessageId string `json:"messageId"` - Partition int `json:"partition"` - Headers map[string]LogValue `json:"headers"` - ProducerId int64 `json:"producerId"` - ProducerEpoch int16 `json:"producerEpoch"` - SequenceNumber int32 `json:"sequenceNumber"` - Deleted bool `json:"deleted"` - Api string `json:"api"` - ClientId string `json:"clientId"` - ScriptFile string `json:"script"` - Actions []*common.Action `json:"actions"` + Offset int64 `json:"offset"` + Key *LogValue `json:"key"` + Message *LogValue `json:"message"` + SchemaId int `json:"schemaId"` + MessageId string `json:"messageId"` + Partition int `json:"partition"` + Headers map[string]*LogValue `json:"headers"` + ProducerId int64 `json:"producerId"` + ProducerEpoch int16 `json:"producerEpoch"` + SequenceNumber int32 `json:"sequenceNumber"` + Deleted bool `json:"deleted"` + Api string `json:"api"` + ClientId string `json:"clientId"` + ScriptFile string `json:"script"` + Actions []*common.Action `json:"actions"` +} + +type KafkaMessageInfo struct { + Key *LogValue `json:"key"` + Message *LogValue `json:"message"` + Deleted bool `json:"deleted"` } type LogValue struct { @@ -34,10 +40,20 @@ type LogValue struct { } func (l *KafkaMessageLog) Title() string { + if l.Key == nil { + return "" + } if l.Key.Value != "" { return l.Key.Value - } else { - return string(l.Key.Binary) + } + return string(l.Key.Binary) +} + +func (l *KafkaMessageLog) Info() any { + return KafkaMessageInfo{ + Key: l.Key, + Message: l.Message, + Deleted: l.Deleted, } } @@ -54,23 +70,30 @@ func (l *KafkaMessageLog) IndexFields() map[string]any { "partition": l.Partition, } - if l.Key.Value != "" { - m["key"] = l.Key.Value - } else { - m["key"] = string(l.Key.Binary) + if l.Key != nil { + if l.Key.Value != "" { + m["key"] = l.Key.Value + } else { + m["key"] = string(l.Key.Binary) + } } - if l.Message.Value != "" { - m["message"] = l.Message.Value - } else { - m["message"] = string(l.Message.Binary) + if l.Message != nil { + if l.Message.Value != "" { + m["message"] = l.Message.Value + } else { + m["message"] = string(l.Message.Binary) + } } var headers []headerIndex for k, h := range l.Headers { - val := h.Value - if val == "" { - val = string(h.Binary) + val := "" + if h != nil { + val = h.Value + if val == "" { + val = string(h.Binary) + } } headers = append(headers, headerIndex{ Key: k, @@ -84,8 +107,8 @@ func (l *KafkaMessageLog) IndexFields() map[string]any { func newKafkaLog(record *kafka.Record) *KafkaMessageLog { return &KafkaMessageLog{ - Key: LogValue{Binary: kafka.Read(record.Key)}, - Message: LogValue{Binary: kafka.Read(record.Value)}, + Key: &LogValue{Binary: kafka.Read(record.Key)}, + Message: &LogValue{Binary: kafka.Read(record.Value)}, Headers: convertHeader(record.Headers), ProducerId: record.ProducerId, ProducerEpoch: record.ProducerEpoch, @@ -122,6 +145,10 @@ func (l *KafkaRequestLogEvent) Title() string { return l.Request.Title() } +func (l *KafkaRequestLogEvent) Info() any { + return l +} + type KafkaRequest interface { Title() string } @@ -154,6 +181,10 @@ func (r *KafkaJoinGroupRequest) Title() string { return fmt.Sprintf("JoinGroup %s", r.GroupName) } +func (r *KafkaJoinGroupRequest) Info() any { + return r +} + type KafkaJoinGroupResponse struct { KafkaResponseError GenerationId int32 `json:"generationId"` @@ -181,6 +212,10 @@ func (r *KafkaSyncGroupRequest) Title() string { return fmt.Sprintf("SyncGroup %s", r.GroupName) } +func (r *KafkaSyncGroupRequest) Info() any { + return r +} + type KafkaSyncGroupResponse struct { KafkaResponseError ProtocolType string `json:"protocolType"` @@ -196,6 +231,10 @@ func (r *KafkaListOffsetsRequest) Title() string { return "ListOffsets" } +func (r *KafkaListOffsetsRequest) Info() any { + return r +} + type KafkaListOffsetsRequestPartition struct { Partition int `json:"partition"` Timestamp int64 `json:"timestamp"` @@ -226,6 +265,10 @@ func (r *KafkaFindCoordinatorRequest) Title() string { return "FindCoordinator" } +func (r *KafkaFindCoordinatorRequest) Info() any { + return r +} + type KafkaFindCoordinatorResponse struct { KafkaResponseError Host string `json:"host"` @@ -244,6 +287,10 @@ func (r *KafkaInitProducerIdRequest) Title() string { return "InitProducerId" } +func (r *KafkaInitProducerIdRequest) Info() any { + return r +} + type KafkaInitProducerIdResponse struct { KafkaResponseError ProducerId int64 `json:"producerId"` diff --git a/providers/asyncapi3/kafka/store/log_test.go b/providers/asyncapi3/kafka/store/log_test.go index 3ce4ee878..61ec252ab 100644 --- a/providers/asyncapi3/kafka/store/log_test.go +++ b/providers/asyncapi3/kafka/store/log_test.go @@ -11,9 +11,9 @@ func TestKafkaLog_Title(t *testing.T) { v := store.KafkaMessageLog{} require.Equal(t, "", v.Title()) - v = store.KafkaMessageLog{Key: store.LogValue{Value: "foo"}} + v = store.KafkaMessageLog{Key: &store.LogValue{Value: "foo"}} require.Equal(t, "foo", v.Title()) - v = store.KafkaMessageLog{Key: store.LogValue{Binary: []byte("foo")}} + v = store.KafkaMessageLog{Key: &store.LogValue{Binary: []byte("foo")}} require.Equal(t, "foo", v.Title()) } diff --git a/providers/asyncapi3/kafka/store/validation.go b/providers/asyncapi3/kafka/store/validation.go index be5a124fb..d5216782f 100644 --- a/providers/asyncapi3/kafka/store/validation.go +++ b/providers/asyncapi3/kafka/store/validation.go @@ -123,9 +123,9 @@ func newMessageValidator(messageId string, msg *asyncapi3.Message, channel *asyn func (mv *messageValidator) Validate(record *kafka.Record) (*KafkaMessageLog, error) { r := &KafkaMessageLog{ - Key: LogValue{}, - Message: LogValue{}, - Headers: make(map[string]LogValue), + Key: &LogValue{}, + Message: &LogValue{}, + Headers: make(map[string]*LogValue), MessageId: mv.messageId, ProducerId: record.ProducerId, ProducerEpoch: record.ProducerEpoch, @@ -183,7 +183,7 @@ type schemaValidator struct { contentType string } -func (v *schemaValidator) Validate(data io.Reader) (interface{}, error) { +func (v *schemaValidator) Validate(data io.Reader) (any, error) { return encoding.DecodeFrom(data, encoding.WithContentType(media.ParseContentType(v.contentType)), encoding.WithParser(v.parser)) } @@ -191,7 +191,7 @@ type headerValidator struct { schema *asyncapi3.SchemaRef } -func (v *headerValidator) Validate(headers []kafka.RecordHeader) (map[string]LogValue, error) { +func (v *headerValidator) Validate(headers []kafka.RecordHeader) (map[string]*LogValue, error) { return parseHeader(headers, v.schema) } @@ -230,15 +230,15 @@ func readSchemaId(payload kafka.Bytes, encoding string) (int, error) { return int(version), err } -func convertHeader(headers []kafka.RecordHeader) map[string]LogValue { - result := map[string]LogValue{} +func convertHeader(headers []kafka.RecordHeader) map[string]*LogValue { + result := map[string]*LogValue{} for _, header := range headers { - result[header.Key] = LogValue{Binary: header.Value} + result[header.Key] = &LogValue{Binary: header.Value} } return result } -func parseHeader(headers []kafka.RecordHeader, sr *asyncapi3.SchemaRef) (map[string]LogValue, error) { +func parseHeader(headers []kafka.RecordHeader, sr *asyncapi3.SchemaRef) (map[string]*LogValue, error) { if sr == nil || sr.Value == nil { return convertHeader(headers), nil } @@ -247,7 +247,7 @@ func parseHeader(headers []kafka.RecordHeader, sr *asyncapi3.SchemaRef) (map[str m[header.Key] = header.Value } - result := map[string]LogValue{} + result := map[string]*LogValue{} switch s := sr.Value.(type) { case *schema.Schema: if s.Properties == nil { @@ -258,7 +258,7 @@ func parseHeader(headers []kafka.RecordHeader, sr *asyncapi3.SchemaRef) (map[str for it := s.Properties.Iter(); it.Next(); { if b, ok := m[it.Key()]; ok { t := it.Value().Type - var v interface{} + var v any var err error switch { case t.IsString(): @@ -296,7 +296,7 @@ func parseHeader(headers []kafka.RecordHeader, sr *asyncapi3.SchemaRef) (map[str if err != nil { return nil, err } - result[it.Key()] = LogValue{Value: fmt.Sprintf("%v", v)} + result[it.Key()] = &LogValue{Value: fmt.Sprintf("%v", v)} done[it.Key()] = true } else if slices.Contains(s.Required, it.Key()) { return nil, fmt.Errorf("required property '%s' is missing in header", it.Key()) @@ -314,9 +314,9 @@ func parseHeader(headers []kafka.RecordHeader, sr *asyncapi3.SchemaRef) (map[str for _, h := range headers { if _, ok := done[h.Key]; !ok { if utf8.Valid(h.Value) { - result[h.Key] = LogValue{Value: string(h.Value), Binary: h.Value} + result[h.Key] = &LogValue{Value: string(h.Value), Binary: h.Value} } else { - result[h.Key] = LogValue{Binary: h.Value} + result[h.Key] = &LogValue{Binary: h.Value} } } } @@ -328,7 +328,7 @@ func parseHeader(headers []kafka.RecordHeader, sr *asyncapi3.SchemaRef) (map[str return nil, err } b, _ := json.Marshal(val) - result[f.Name] = LogValue{Value: string(b)} + result[f.Name] = &LogValue{Value: string(b)} } } } diff --git a/providers/asyncapi3/kafka/store/validation_test.go b/providers/asyncapi3/kafka/store/validation_test.go index 787870430..ec4773e13 100644 --- a/providers/asyncapi3/kafka/store/validation_test.go +++ b/providers/asyncapi3/kafka/store/validation_test.go @@ -58,12 +58,12 @@ func TestValidation(t *testing.T) { require.Equal(t, &store.KafkaMessageLog{ Offset: 1, - Key: store.LogValue{Value: "", Binary: []byte("key-bar")}, - Message: store.LogValue{Value: "", Binary: []byte("bar")}, + Key: &store.LogValue{Value: "", Binary: []byte("key-bar")}, + Message: &store.LogValue{Value: "", Binary: []byte("bar")}, SchemaId: 0, MessageId: "foo", Partition: 0, - Headers: map[string]store.LogValue{}, + Headers: map[string]*store.LogValue{}, Deleted: false, Api: "test", }, @@ -71,12 +71,12 @@ func TestValidation(t *testing.T) { require.Equal(t, &store.KafkaMessageLog{ Offset: 0, - Key: store.LogValue{Value: "", Binary: []byte("key-foo")}, - Message: store.LogValue{Value: "", Binary: []byte("foo")}, + Key: &store.LogValue{Value: "", Binary: []byte("key-foo")}, + Message: &store.LogValue{Value: "", Binary: []byte("foo")}, SchemaId: 0, MessageId: "foo", Partition: 0, - Headers: map[string]store.LogValue{}, + Headers: map[string]*store.LogValue{}, Deleted: false, Api: "test", }, diff --git a/providers/asyncapi3/mqtt/store/log.go b/providers/asyncapi3/mqtt/store/log.go index b972b45ef..fb55fdced 100644 --- a/providers/asyncapi3/mqtt/store/log.go +++ b/providers/asyncapi3/mqtt/store/log.go @@ -8,7 +8,7 @@ import ( type LogMessage struct { Topic string `json:"topic"` - Message LogValue `json:"message"` + Message *LogValue `json:"message"` Retain bool `json:"retain"` MessageId string `json:"messageId"` Api string `json:"api"` @@ -17,6 +17,11 @@ type LogMessage struct { Actions []*common.Action `json:"actions"` } +type EventMqttInfo struct { + Topic string `json:"topic"` + Message *LogValue `json:"message"` +} + type LogValue struct { Value string `json:"value"` Binary []byte `json:"binary"` @@ -26,6 +31,13 @@ func (l *LogMessage) Title() string { return l.Topic } +func (l *LogMessage) Info() any { + return EventMqttInfo{ + Topic: l.Topic, + Message: l.Message, + } +} + type RequestLogEvent struct { Api string `json:"api"` Type mqtt.Type `json:"type"` @@ -37,6 +49,10 @@ func (r *RequestLogEvent) Title() string { return r.Request.Title() } +func (r *RequestLogEvent) Info() any { + return r +} + type RequestLog interface { Title() string } @@ -54,6 +70,10 @@ func (r *ConnectRequest) Title() string { return "Connect" } +func (r *ConnectRequest) Info() any { + return r +} + type PublishMessage struct { QoS byte `json:"qos"` Retain bool `json:"retain"` @@ -75,6 +95,10 @@ func (r *SubscribeRequest) Title() string { return "Subscribe" } +func (r *SubscribeRequest) Info() any { + return r +} + type SubscribeTopic struct { Name string `json:"name"` QoS byte `json:"qos"` @@ -92,6 +116,10 @@ func (r *DisconnectRequest) Title() string { return "Disconnect" } +func (r *DisconnectRequest) Info() any { + return r +} + func (s *Store) logRequest(req RequestLog, res any, ctx *mqtt.ClientContext) { log := &RequestLogEvent{Api: s.cfg.Info.Name, Request: req} diff --git a/providers/asyncapi3/mqtt/store/publish.go b/providers/asyncapi3/mqtt/store/publish.go index 931d3d55d..62a4d725f 100644 --- a/providers/asyncapi3/mqtt/store/publish.go +++ b/providers/asyncapi3/mqtt/store/publish.go @@ -170,7 +170,7 @@ func (s *Store) logMessage(messageId string, topic *Topic, publish *mqtt.Publish err := s.eh.Push(&LogMessage{ Topic: topic.Name, MessageId: messageId, - Message: LogValue{ + Message: &LogValue{ Value: string(publish.Data), Binary: publish.Data, }, diff --git a/providers/asyncapi3/websocket/log.go b/providers/asyncapi3/websocket/log.go index 1550b6be3..64a06f390 100644 --- a/providers/asyncapi3/websocket/log.go +++ b/providers/asyncapi3/websocket/log.go @@ -41,6 +41,10 @@ func (l *MessageLog) Title() string { return "" } +func (l *MessageLog) Info() any { + return l +} + func messageLog(c *Channel, data []byte, messageId string, client *Client, direction Direction) *MessageLog { l := &MessageLog{ Channel: c.Name, @@ -87,6 +91,10 @@ func (l *ConnectLog) Title() string { return fmt.Sprintf("Connect to %s", l.Channel) } +func (l *ConnectLog) Info() any { + return l +} + type CloseLog struct { Type string `json:"type"` Channel string `json:"channel"` @@ -111,3 +119,7 @@ func closeLog(c *Channel, client *Client, reason, closedBy string) *CloseLog { func (l *CloseLog) Title() string { return fmt.Sprintf("Connect to %s", l.Channel) } + +func (l *CloseLog) Info() any { + return l +} diff --git a/providers/directory/log.go b/providers/directory/log.go index 3e1ad1293..4456728bb 100644 --- a/providers/directory/log.go +++ b/providers/directory/log.go @@ -5,8 +5,17 @@ import ( "mokapi/engine/common" "mokapi/ldap" "mokapi/runtime/events" + "strings" ) +type LogInfo struct { + Operation string `json:"operation"` + Dn string `json:"dn,omitempty"` + Criteria string `json:"criteria,omitempty"` + Status string `json:"status,omitempty"` + Duration int64 `json:"duration"` +} + type SearchLog struct { Request *SearchRequest `json:"request"` Response *SearchResponse `json:"response"` @@ -15,16 +24,16 @@ type SearchLog struct { } type SearchRequest struct { - Operation string `json:"operation"` - BaseDN string `json:"baseDN"` - Scope string `json:"scope"` - DereferencePolicy int64 `json:"dereferencePolicy"` - SizeLimit int64 `json:"sizeLimit"` - TimeLimit int64 `json:"timeLimit"` - TypesOnly bool `json:"typesOnly"` - Filter string `json:"filter"` - Attributes []string `json:"attributes"` - Controls []interface{} `json:"controls"` + Operation string `json:"operation"` + BaseDN string `json:"baseDN"` + Scope string `json:"scope"` + DereferencePolicy int64 `json:"dereferencePolicy"` + SizeLimit int64 `json:"sizeLimit"` + TimeLimit int64 `json:"timeLimit"` + TypesOnly bool `json:"typesOnly"` + Filter string `json:"filter"` + Attributes []string `json:"attributes"` + Controls []any `json:"controls"` } type SearchResponse struct { @@ -321,14 +330,40 @@ func (l *BindLog) Title() string { return fmt.Sprintf("%s %s %s", l.Request.Auth, l.Request.Name, l.Request.Password) } +func (l *BindLog) Info() any { + return LogInfo{ + Operation: l.Request.Operation, + Dn: l.Request.Name, + Status: l.Response.Status, + Duration: l.Duration, + } +} + func (l *UnbindLog) Title() string { return "" } +func (l *UnbindLog) Info() any { + return LogInfo{ + Operation: l.Request.Operation, + Duration: l.Duration, + } +} + func (l *SearchLog) Title() string { return l.Request.Filter } +func (l *SearchLog) Info() any { + return LogInfo{ + Operation: l.Request.Operation, + Dn: l.Request.BaseDN, + Criteria: l.Request.Filter, + Status: l.Response.Status, + Duration: l.Duration, + } +} + func (l *SearchLog) IndexFields() map[string]any { m := map[string]any{ "operation": l.Request.Operation, @@ -343,18 +378,78 @@ func (l *CompareLog) Title() string { return fmt.Sprintf("%s %s", l.Request.Operation, l.Request.Value) } +func (l *CompareLog) Info() any { + return LogInfo{ + Operation: l.Request.Operation, + Dn: l.Request.Dn, + Criteria: fmt.Sprintf("%s == %s", l.Request.Attribute, l.Request.Value), + Status: l.Response.Status, + Duration: l.Duration, + } +} + func (l *ModifyLog) Title() string { return fmt.Sprintf("%s %s", l.Request.Operation, l.Request.Dn) } +type ModifyInfo struct { + Operation string `json:"operation"` + Dn string `json:"dn,omitempty"` + Criteria []string `json:"criteria,omitempty"` + Status string `json:"status,omitempty"` + Duration int64 `json:"duration"` +} + +func (l *ModifyLog) Info() any { + criteria := make([]string, 0, len(l.Request.Items)) + for _, i := range l.Request.Items { + criteria = append(criteria, fmt.Sprintf("%s %s", strings.ToLower(i.Modification), i.Attribute.Type)) + } + return ModifyInfo{ + Operation: l.Request.Operation, + Dn: l.Request.Dn, + Criteria: criteria, + Status: l.Response.Status, + Duration: l.Duration, + } +} + func (l *ModifyDNLog) Title() string { return fmt.Sprintf("%s %s", l.Request.Operation, l.Request.Dn) } +func (l *ModifyDNLog) Info() any { + return LogInfo{ + Operation: l.Request.Operation, + Dn: l.Request.Dn, + Criteria: l.Request.NewRdn, + Status: l.Response.Status, + Duration: l.Duration, + } +} + func (l *DeleteLog) Title() string { return fmt.Sprintf("%s %s", l.Request.Operation, l.Request.Dn) } +func (l *DeleteLog) Info() any { + return LogInfo{ + Operation: l.Request.Operation, + Dn: l.Request.Dn, + Status: l.Response.Status, + Duration: l.Duration, + } +} + func (l *AddLog) Title() string { return fmt.Sprintf("%s %s", l.Request.Operation, l.Request.Dn) } + +func (l *AddLog) Info() any { + return LogInfo{ + Operation: l.Request.Operation, + Dn: l.Request.Dn, + Status: l.Response.Status, + Duration: l.Duration, + } +} diff --git a/providers/mail/log.go b/providers/mail/log.go index 8a8fa07a3..67b912e04 100644 --- a/providers/mail/log.go +++ b/providers/mail/log.go @@ -19,6 +19,14 @@ type Log struct { Actions []*common.Action `json:"actions"` } +type LogInfo struct { + MessageId string `json:"messageId"` + From string `json:"from"` + To []string `json:"to"` + Subject string `json:"subject"` + Duration int64 `json:"duration"` +} + func NewLogEvent(msg *smtp.Message, ctx *smtp.ClientContext, eh events.Handler, traits events.Traits) *Log { event := &Log{ From: ctx.From, @@ -46,6 +54,16 @@ func (l *Log) Title() string { return fmt.Sprintf("%s", l.Subject) } +func (l *Log) Info() any { + return LogInfo{ + MessageId: l.MessageId, + From: l.From, + To: l.To, + Subject: l.Subject, + Duration: l.Duration, + } +} + func (l *Log) IndexFields() map[string]any { m := map[string]any{ "from": l.From, diff --git a/providers/openapi/handler_test.go b/providers/openapi/handler_test.go index 2d49d91ce..c20b4567f 100644 --- a/providers/openapi/handler_test.go +++ b/providers/openapi/handler_test.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" "io" + "maps" "mokapi/config/dynamic" "mokapi/config/dynamic/dynamictest" "mokapi/engine/common" @@ -785,9 +786,7 @@ func TestResolveEndpoint(t *testing.T) { h := openapi.NewHandler(config, f, e) err := h.ServeHTTP(rw, r) if err != nil { - for k, v := range err.Header { - rw.Header()[k] = v - } + maps.Copy(rw.Header(), err.Header) http.Error(rw, err.Message, err.StatusCode) } }, config) @@ -1199,9 +1198,7 @@ func TestHandler_Event(t *testing.T) { r = r.WithContext(ctx) httpErr := h.ServeHTTP(rw, r) if httpErr != nil { - for k, v := range httpErr.Header { - rw.Header()[k] = v - } + maps.Copy(rw.Header(), httpErr.Header) http.Error(rw, httpErr.Message, httpErr.StatusCode) } }, config, sm) @@ -1239,7 +1236,6 @@ func TestHandler_Log(t *testing.T) { } for _, tc := range testcases { - tc := tc t.Run(tc.name, func(t *testing.T) { test.NewNullLogger() @@ -1259,9 +1255,7 @@ func TestHandler_Log(t *testing.T) { h := openapi.NewHandler(config, f, m) err := h.ServeHTTP(rw, r) if err != nil { - for k, v := range err.Header { - rw.Header()[k] = v - } + maps.Copy(rw.Header(), err.Header) http.Error(rw, err.Message, err.StatusCode) } }, config, m) @@ -1474,8 +1468,8 @@ func TestHandler_Event_TypeScript(t *testing.T) { require.Contains(t, rr.Body.String(), `{"foo":`) result := sm.GetEvents(events.NewTraits().WithNamespace("http")) - log := result[0].Data.(*openapi.HttpLog) - require.Equal(t, "no configuration was found for HTTP status code 404, https://swagger.io/docs/specification/describing-responses", log.Actions[0].Error.Message) + hl := result[0].Data.(*openapi.HttpLog) + require.Equal(t, "no configuration was found for HTTP status code 404, https://swagger.io/docs/specification/describing-responses", hl.Actions[0].Error.Message) }, }, { @@ -1592,14 +1586,13 @@ func TestHandler_Event_TypeScript(t *testing.T) { require.Contains(t, rr.Body.String(), `{"foo":`) result := sm.GetEvents(events.NewTraits().WithNamespace("http")) - log := result[0].Data.(*openapi.HttpLog) - require.Equal(t, "content type 'text/plain' is not specified for HTTP status code 404", log.Actions[0].Error.Message) + hl := result[0].Data.(*openapi.HttpLog) + require.Equal(t, "content type 'text/plain' is not specified for HTTP status code 404", hl.Actions[0].Error.Message) }, }, } for _, tc := range testcases { - tc := tc t.Run(tc.name, func(t *testing.T) { tc.test(t) }) @@ -1662,10 +1655,10 @@ func TestHandler_Parameter(t *testing.T) { } type testEngine struct { - emit func(event string, args ...interface{}) []*common.Action + emit func(event string, args ...any) []*common.Action } -func (e *testEngine) Emit(event string, args ...interface{}) []*common.Action { +func (e *testEngine) Emit(event string, args ...any) []*common.Action { if e.emit != nil { return e.emit(event, args...) } diff --git a/providers/openapi/log.go b/providers/openapi/log.go index 388a1c6ec..ee93412ad 100644 --- a/providers/openapi/log.go +++ b/providers/openapi/log.go @@ -24,6 +24,13 @@ type HttpLog struct { ClientIP string `json:"clientIP"` } +type HttpLogInfo struct { + Url string `json:"url"` + StatusCode int `json:"statusCode"` + Duration int64 `json:"duration"` + Deprecated bool `json:"deprecated"` +} + type HttpRequestLog struct { Method string `json:"method"` Url string `json:"url"` @@ -112,6 +119,15 @@ func (l *HttpLog) Title() string { return l.Request.Url } +func (l *HttpLog) Info() any { + return HttpLogInfo{ + Url: l.Request.Url, + StatusCode: l.Response.StatusCode, + Duration: l.Duration, + Deprecated: l.Deprecated, + } +} + func (l *HttpLog) IndexFields() map[string]any { m := map[string]any{ "request": l.Request, diff --git a/runtime/events/events.go b/runtime/events/events.go index af3b5cd3f..9fad52309 100644 --- a/runtime/events/events.go +++ b/runtime/events/events.go @@ -18,6 +18,7 @@ type Handler interface { type EventData interface { Title() string + Info() any } type StoreManager struct { diff --git a/runtime/events/eventstest/event.go b/runtime/events/eventstest/event.go index 0f8f9faba..90f7ecc54 100644 --- a/runtime/events/eventstest/event.go +++ b/runtime/events/eventstest/event.go @@ -3,7 +3,7 @@ package eventstest import "mokapi/runtime/events" type Event struct { - Name string + Name string `json:"name"` Api string `json:"api"` } @@ -11,6 +11,10 @@ func (e *Event) Title() string { return e.Name } +func (e *Event) Info() any { + return e +} + func (e *Event) IndexFields() map[string]any { return map[string]any{ "name": e.Name, diff --git a/runtime/index.go b/runtime/index.go index d02116281..e90c60bf8 100644 --- a/runtime/index.go +++ b/runtime/index.go @@ -175,6 +175,9 @@ func (s *SearchIndex) runWorker(ctx context.Context) { if batchSize == 0 { return } + if s.idx == nil { + return + } if err := s.idx.Batch(batch); err != nil { println("batch failed:", err.Error()) diff --git a/runtime/logs.go b/runtime/logs.go index fe021daca..a6ebc8c14 100644 --- a/runtime/logs.go +++ b/runtime/logs.go @@ -56,6 +56,10 @@ func (d *LogData) Title() string { return fmt.Sprintf("%v: %v", d.Level, msg) } +func (d *LogData) Info() any { + return d +} + func extractTraits(entry *log.Entry) events.Traits { traits := events.NewTraits() if ns, ok := entry.Data["namespace"].(string); ok { diff --git a/webui/e2e/mocks/common.yml b/webui/e2e/mocks/common.yml index e6fd78fae..085a411f9 100644 --- a/webui/e2e/mocks/common.yml +++ b/webui/e2e/mocks/common.yml @@ -53,64 +53,6 @@ components: type: string value: type: number - Events: - type: array - items: - $ref: '#/components/schemas/Event' - Event: - type: object - properties: - id: - type: string - traits: - type: object - additionalProperties: - type: string - time: - type: string - data: - anyOf: - - $ref: '#/components/schemas/KafkaEvent' - - description: any - type: object - additionalProperties: true - KafkaEvent: - type: object - properties: - offset: - type: integer - key: - $ref: '#/components/schemas/KafkaValue' - message: - $ref: '#/components/schemas/KafkaValue' - schemaId: - type: integer - partition: - type: integer - headers: - type: object - additionalProperties: - type: object - properties: - value: - type: string - binary: - type: string - deleted: - type: boolean - producerId: - type: number - producerEpoch: - type: number - sequenceNumber: - type: number - KafkaValue: - type: object - properties: - value: - type: string - binary: - type: string Error: type: object properties: diff --git a/webui/e2e/mocks/dashboard.ts b/webui/e2e/mocks/dashboard.ts new file mode 100644 index 000000000..f9cea4957 --- /dev/null +++ b/webui/e2e/mocks/dashboard.ts @@ -0,0 +1,276 @@ +import { app } from 'mokapi' +import { get } from 'mokapi/http' +import { clusters as kafkaClusters, events as kafkaEvents, configs as kafkaConfigs } from './kafka' +import { apps as httpServices, events as httpEvents, configs as httpConfigs } from './http' +import { services as mailServices, mailEvents, getMail, getAttachment } from './mail' +import { server as ldapServers, searches } from './ldap' +import { clusters as mqttClusters, events as mqttEvents } from './mqtt' +import { services as websocketServices, events as websocketEvents } from './websocket' +import { sum, max } from './metrics' +import { getInfo, matchTraits } from './helpers' +import { jobs } from './jobs' +import { match, type Query } from '../../lib/events' + +export default function() { + const apiBaseUrl = `http://localhost:8091/mokapi` + const { version, buildTime } = get(`${apiBaseUrl}/api/info`).json() + + app.http() + .use((_, res) => { + res.headers["Access-Control-Allow-Methods"] = "*" + res.headers["Access-Control-Allow-Headers"] = "*" + res.headers["Access-Control-Allow-Origin"] = "*" + res.headers['Mokapi-Version'] = version + res.headers['Mokapi-Build-Time'] = buildTime + }) + .operation('info', (_, res) => { + res.data = { + version: "0.11.0", + activeServices: ["http", "kafka", "ldap", "mail", "mqtt", "websocket"], + search: { enabled: true } + } + }) + .operation('services', (req, res) => { + res.data = getServices(req.query['type']) + }) + .operation('events', (req, res) => { + const page = req.query['page'] ? parseInt(req.query['page']) : 1 + const size = req.query['size'] ? parseInt(req.query['size']) : 25 + + const items = events.filter(x => match(x, query(req.query))) + res.data = { + items: items.map(eventInfo).slice((page - 1) * size, page * size), + page: page, + size: size, + total: items.length + } + }) + .operation('event', (req, res) => { + const event = events.find(x => x.id === req.path['id']) + if (!event){ + res.rebuild(404) + return + } + res.data = event + }) +} + + +function getServices(type: string) { + const services = [] + if (!type || type === 'http') { + services.push(...httpServices.map(x => { + return { + ...getInfo(x, 'http'), + metrics: { + http_requests_total: sum('http_requests_total', { name: 'service', value: x.name }), + http_requests_errors_total: sum('http_requests_errors_total', { name: 'service', value: x.name }), + http_request_timestamp: max('http_request_timestamp', { name: 'service', value: x.name }) + } + } + })) + } + if (!type || type === 'kafka') { + services.push(...kafkaClusters.map(x => { + return { + ...getInfo(x, 'kafka'), + metrics: { + kafka_messages_total: sum('kafka_messages_total', { name: 'service', value: x.name }), + kafka_message_timestamp: max('kafka_message_timestamp', { name: 'service', value: x.name }) + } + } + })) + } + if (!type || type === 'mail') { + services.push(...mailServices.map(x => { + return { + ...getInfo(x, 'mail'), + metrics: { + mail_mails_total: sum('mail_mails_total', { name: 'service', value: x.name }), + mail_mail_timestamp: max('mail_mail_timestamp', { name: 'service', value: x.name }) + } + } + })) + } + if (!type || type === 'ldap') { + services.push(...ldapServers.map(x => { + return { + ...getInfo(x, 'ldap'), + metrics: { + ldap_requests_total: sum('ldap_requests_total', { name: 'service', value: x.name }), + ldap_request_timestamp: max('ldap_request_timestamp', { name: 'service', value: x.name }) + } + } + })) + } + if (!type || type === 'mqtt') { + services.push(...mqttClusters.map(x => { + return { + ...getInfo(x, 'mqtt'), + metrics: { + mqtt_messages_total: sum('mqtt_messages_total', { name: 'service', value: x.name }), + mqtt_message_timestamp: max('mqtt_message_timestamp', { name: 'service', value: x.name }) + } + } + })) + } + if (!type || type === 'websocket') { + services.push(...websocketServices.map(x => { + return { + ...getInfo(x, 'websocket'), + metrics: { + websocket_messages_total: sum('websocket_messages_total', { name: 'service', value: x.name }), + websocket_message_timestamp: max('websocket_message_timestamp', { name: 'service', value: x.name }) + } + } + })) + } + + return services +} + +const events = [ + ...httpEvents, + ...kafkaEvents, + ...mqttEvents, + ...websocketEvents, + ...mailEvents, + ...searches, + ...jobs +] + +function eventInfo(event: ServiceEvent) { + const {data, ...info} = event + switch (event.traits.namespace) { + case 'http': + return { + ...info, + data: { + url: event.data.request.url, + statusCode: event.data.response.statusCode, + duration: event.data.duration, + deprecated: event.data.deprecated + } + } + case 'kafka': + switch (event.traits.type) { + case 'message': + return { + ...info, + data: { + topic: event.data.topic, + key: event.data.key, + message: event.data.message, + deleted: event.data.deleted + } + } + case 'request': + return event + } + case 'mqtt': + switch (event.traits.type) { + case 'message': + return { + ...info, + data: { + topic: event.data.topic, + message: event.data.message + } + } + case 'request': + return event + } + case 'websocket': + return event + case 'ldap': + return { + ...info, + data: { + operation: event.data.request.operation, + dn: dn(event), + criteria: criteria(event), + status: event.data.response.status ?? '-', + duration: event.data.duration, + } + } + case 'mail': + return { + ...info, + data: { + messageId: event.data.messageId, + from: event.data.from, + to: event.data.to, + subject: event.data.subject, + duration: event.data.duration + } + } + case 'job': + return event + } +} + +function dn(e: ServiceEvent) { + switch (e.data.request.operation) { + case 'Bind': + return e.data.request.name; + case 'Search': + return e.data.request.baseDN + case 'Modify': + case 'Add': + case 'Delete': + case 'ModifyDN': + case 'Compare': + return e.data.request.dn + } +} + +function criteria(e: ServiceEvent) { + switch (e.data.request.operation) { + case 'Search': + return e.data.request.filter + case 'Compare': + return `${e.data.request.attribute} == ${e.data.request.value}` + case 'Modify': + const result = [] + for (const item of e.data.request.items) { + result.push(`${item.modification.toLocaleLowerCase()} ${item.attribute.type}`) + } + return result + case 'ModifyDN': + return e.data.request.newRdn + default: + undefined; + } +} + +function query(q: Record): Query { + const result: Query = { labels: Object.keys(q.traits).map(x => ({ name: x, value: q.traits[x] })) } + + if (q.http) { + result.http = { + url: q.http.url, + statusCode: q.http.statusCode, + clientIP: q.http.clientIP + } + + if (q.http.method) { + result.http.methods = q.http.method.split(',') + } + if (q.http.requestHeader) { + result.http.requestHeaders = [] + for (const h of q.http.requestHeader) { + const kv = h.split(':', 2) + result.http.requestHeaders.push({ name: kv[0], value: kv.length == 1 ? '' : kv[1] }) + } + } + if (q.http.responseHeader) { + result.http.responseHeaders = [] + for (const h of q.http.responseHeader) { + const kv = h.split(':', 2) + result.http.responseHeaders.push({ name: kv[0], value: kv.length == 1 ? '' : kv[1] }) + } + } + } + + return result +} \ No newline at end of file diff --git a/webui/e2e/mocks/dashboard.yml b/webui/e2e/mocks/dashboard.yml index eec8c6a69..23da64489 100644 --- a/webui/e2e/mocks/dashboard.yml +++ b/webui/e2e/mocks/dashboard.yml @@ -382,43 +382,9 @@ paths: /api/services/kafka/{cluster}/clients/{clientId}: $ref: './kafka.yml#/components/pathItems/Client' /api/events: - get: - summary: list of events - operationId: events - parameters: - - name: traits - in: query - required: false - schema: - type: object - additionalProperties: - type: string - responses: - "200": - description: name of kafka cluster - content: - application/json: - schema: - $ref: 'common.yml#/components/schemas/Events' + $ref: './events.yaml#/components/pathItems/Events' /api/events/{id}: - get: - summary: Information about the kafka cluster - operationId: event - parameters: - - name: id - in: path - required: true - schema: - type: string - responses: - "200": - description: name of kafka cluster - content: - application/json: - schema: - $ref: 'common.yml#/components/schemas/Event' - "404": - description: no event exists for specified id + $ref: './events.yaml#/components/pathItems/Event' /api/metrics: get: summary: get list of metrics diff --git a/webui/e2e/mocks/events.yaml b/webui/e2e/mocks/events.yaml new file mode 100644 index 000000000..afc349a10 --- /dev/null +++ b/webui/e2e/mocks/events.yaml @@ -0,0 +1,125 @@ +components: + pathItems: + Events: + get: + summary: list of events + operationId: events + parameters: + - $ref: '#/components/parameters/Traits' + - $ref: '#/components/parameters/Http' + responses: + "200": + description: name of kafka cluster + content: + application/json: + schema: + $ref: '#/components/schemas/Events' + Event: + get: + summary: Information about the kafka cluster + operationId: event + parameters: + - name: id + in: path + required: true + schema: + type: string + description: Specify multiple values separated by commas or by repeating the parameter (OR logic). Case-insensitive. + responses: + "200": + description: name of kafka cluster + content: + application/json: + schema: + $ref: '#/components/schemas/Event' + "404": + description: no event exists for specified id + parameters: + Traits: + name: traits + in: query + required: false + style: deepObject + explode: true + schema: + type: object + additionalProperties: + type: string + Http: + name: http + in: query + required: false + style: deepObject + explode: true + schema: + $ref: '#/components/schemas/HttpEventFilter' + schemas: + Events: + type: object + properties: + items: + type: array + items: + $ref: '#/components/schemas/Event' + page: + type: integer + size: + type: integer + total: + type: integer + Event: + type: object + properties: + id: + type: string + traits: + type: object + additionalProperties: + type: string + time: + type: string + data: + anyOf: + - $ref: 'kafka.yml#/components/schemas/KafkaEvent' + - description: any + type: object + additionalProperties: true + HttpEventFilter: + type: object + properties: + method: + type: string + description: | + Filters by HTTP method. Specify multiple methods separated by commas or by repeating the parameter (OR logic). + Case-insensitive. + pattern: '^[A-Za-z]+(,[A-Za-z]+)*$' + url: + type: string + requestHeader: + type: array + items: + type: string + description: | + Format Name:ValueRegex. Without :, only checks that the header exists; :Regex matches against all headers. Repeat the parameter to combine filters with AND. + example: 'Content-Type:application/json' + statusCode: + type: string + examples: + - 200,201 + - '-200' + - 200-299 + responseHeader: + type: array + items: + type: string + description: | + Format Name:ValueRegex. Without :, only checks that the header exists; :Regex matches against all headers. Repeat the parameter to combine filters with AND. + example: 'Content-Type:application/json' + clientIP: + type: string + examples: + - 192.168.0.1 + - 192.168.0.1,10.0.0.1 + - '!127.0.0.1' + - 192.168.0.0/24 + diff --git a/webui/e2e/mocks/helpers.ts b/webui/e2e/mocks/helpers.ts new file mode 100644 index 000000000..c0502cb7a --- /dev/null +++ b/webui/e2e/mocks/helpers.ts @@ -0,0 +1,28 @@ +export function getInfo(config: Config, type: string) { + const info: Info = { + name: config.name, + description: config.description, + version: config.version, + type: type, + } + if (config.contact) { + info.contact = { + name: config.contact.name, + email: config.contact.email, + url: config.contact.url + } + } + if (config.status) { + info.status = config.status + } + return info +} + +export function matchTraits(e: ServiceEvent, traits: Traits) { + for (let t in traits) { + if (e.traits[t] !== traits[t]) { + return false + } + } + return true +} \ No newline at end of file diff --git a/webui/e2e/mocks/services_http.js b/webui/e2e/mocks/http.ts similarity index 85% rename from webui/e2e/mocks/services_http.js rename to webui/e2e/mocks/http.ts index e69630042..d727bc9f9 100644 --- a/webui/e2e/mocks/services_http.js +++ b/webui/e2e/mocks/http.ts @@ -1,4 +1,99 @@ -import {metrics} from "metrics" +import { app } from 'mokapi' +import { metrics, sum, max} from './metrics' + +export default function () { + app.http() + .operation('serviceHttp', (req, res) => { + const name = req.path['name'] + const service = apps.find(x => x.name === name) + if (!service) { + res.rebuild(404) + } else { + res.data = { + name: service.name, + description: service.description, + version: service.version, + ...service.contact ? { contact: service.contact } : {}, + servers: service.servers, + paths: service.paths.map(p => { + return { + path: p.path, + ...('summary' in p ? { summary: p.summary } : {}), + ...('description' in p ? { description: p.description } : {}), + status: p.status, + ...('errors' in p ? { errors: p.errors } : {}), + operations: p.operations.map(o => { + return { + method: o.method, + ...('summary' in o ? { summary: o.summary } : {}), + ...('description' in o ? { description: o.description } : {}), + ...('operationId' in o ? { operationId: o.operationId } : {}), + deprecated: ('deprecated' in o ? o.deprecated : false), + ...('tags' in o ? { tags: o.tags } : {}), + status: o.status, + ...('errors' in o ? { errors: o.errors } : {}), + metrics: { + http_requests_total: sum('http_requests_total', { name: 'service', value: service.name }, { name: 'endpoint', value: p.path }, { name: 'method', value: o.method.toUpperCase() }), + http_requests_errors_total: sum('http_requests_errors_total', { name: 'service', value: service.name }, { name: 'endpoint', value: p.path }, { name: 'method', value: o.method.toUpperCase() }), + http_request_timestamp: max('http_request_timestamp', { name: 'service', value: service.name }, { name: 'endpoint', value: p.path }, { name: 'method', value: o.method.toUpperCase() }) + } + } + }) + } + }), + } + } + }) + .operation('httpOperations', (req, res) => { + const name = req.path['name'] + const service = apps.find(x => x.name === name) + if (!service) { + res.rebuild(404) + return + } + const method = req.query['method'] + const path = req.query['path'] + let operations = [] + for (const p of service.paths) { + if (!path || p.path === path) { + for (const o of p.operations) { + if (!method || o.method.toLowerCase() === method.toLowerCase()) { + operations.push({ + ...o, + metrics: { + http_requests_total: sum('http_requests_total', { name: 'service', value: service.name }, { name: 'endpoint', value: p.path}, { name: 'method', value: o.method.toUpperCase() }), + http_requests_errors_total: sum('http_requests_errors_total', { name: 'service', value: service.name }, { name: 'endpoint', value: p.path}, { name: 'method', value: o.method.toUpperCase() }), + http_request_timestamp: max('http_request_timestamp', { name: 'service', value: service.name }, { name: 'endpoint', value: p.path}, { name: 'method', value: o.method.toUpperCase() }) + } + }) + } + } + } + } + res.data = operations + }) + .operation('httpOpenApi', (req, res) => { + const serviceName = req.path['name'] + const format = req.path['format'] + const service = apps.find(x => x.name === serviceName) + if (!service) { + res.rebuild(404) + return + } + switch (format) { + case 'yaml': + res.headers['Content-Type'] = 'application/yaml' + res.data = service + return + case 'json': + res.headers['Content-Type'] = 'application/json' + res.data = service + return + default: + throw new Error(`Unsupported format: ${format}`) + } + }) +} const category = { type: "object", @@ -11,7 +106,7 @@ const category = { type: "string" } }, - xml: {name: "Category"} + xml: { name: "Category" } } const tag = { @@ -25,7 +120,7 @@ const tag = { type: "string" } }, - xml: {name: "Tag"} + xml: { name: "Tag" } } const pet = { @@ -70,7 +165,7 @@ const pet = { enum: ["available", "pending", "sold"] } }, - xml: {name: "Pet"} + xml: { name: "Pet" } } const book = { @@ -160,7 +255,7 @@ export let apps = [ { type: "query", name: "$format", - schema: {type: "string"}, + schema: { type: "string" }, required: false, deprecated: false, explode: false, @@ -414,7 +509,7 @@ export let apps = [ headers: [ { name: "Expires-After", - schema: {type: "string", format: "date-time"}, + schema: { type: "string", format: "date-time" }, description: "date in UTC when token expires", } ], @@ -626,7 +721,7 @@ export let events = [ path: "/pet", method: 'POST' }, - time: '2023-02-13T08:49:25.482366+01:00', + time: '2023-02-13T08:49:25.482366+01:00', data: { deprecated: true, request: { @@ -665,10 +760,10 @@ export let events = [ event: "http" }, logs: [ - { level: 'info', message: 'a custom log message, see https://mokapi.io'}, - { level: 'warn', message: 'a warn log message'}, - { level: 'error', message: 'a error log message'}, - { level: 'debug', message: 'a debug log message'} + { level: 'info', message: 'a custom log message, see https://mokapi.io' }, + { level: 'warn', message: 'a warn log message' }, + { level: 'error', message: 'a error log message' }, + { level: 'debug', message: 'a debug log message' } ], parameters: [ JSON.stringify({ @@ -689,7 +784,7 @@ export let events = [ headers: { "Content-Type": "application/json" }, - data: {"foo":"bar"} + data: { "foo": "bar" } }) ] } @@ -725,7 +820,7 @@ export let events = [ statusCode: 201, headers: { "Content-Type": "application/json", - "Set-Cookie": "sessionId=38afes7a8" + "Set-Cookie": "sessionId=38afes6v3" }, size: 512 }, diff --git a/webui/e2e/mocks/http_handler.js b/webui/e2e/mocks/http_handler.js index a18e40437..c5657d9f8 100644 --- a/webui/e2e/mocks/http_handler.js +++ b/webui/e2e/mocks/http_handler.js @@ -1,326 +1,24 @@ import { on, sleep } from 'mokapi' import { clusters as kafkaClusters, events as kafkaEvents, configs as kafkaConfigs } from './kafka.ts' -import { apps as httpServices, events as httpEvents, configs as httpConfigs } from './services_http.js' -import { services as mailServices, mailEvents, getMail, getAttachment } from './mail.js' -import { server as ldapServers, searches } from './ldap.js' -import { clusters as mqttClusters, events as mqttEvents } from './mqtt.js' -import { services as websocketServices, events as websocketEvents } from './websocket.js' +import { apps as httpServices, events as httpEvents, configs as httpConfigs } from './http' +import { services as mailServices, mailEvents, getMail, getAttachment } from './mail' +import { server as ldapServers, searches } from './ldap' +import { clusters as mqttClusters, events as mqttEvents } from './mqtt' +import { services as websocketServices, events as websocketEvents } from './websocket' import { metrics, sum, max } from './metrics.ts' import { get, post, fetch } from 'mokapi/http' -import { base64 } from 'mokapi/encoding'; -import { yaml } from 'mokapi/yaml' const configs = {} -const jobs = [] export default async function() { const apiBaseUrl = `http://localhost:8091/mokapi` - const res = get(`${apiBaseUrl}/api/info`) - const { version, buildTime } = res.json() on('http', function(request, response) { - response.headers["Access-Control-Allow-Methods"] = "*" - response.headers["Access-Control-Allow-Headers"] = "*" - response.headers["Access-Control-Allow-Origin"] = "*" - response.headers['Mokapi-Version'] = version - response.headers['Mokapi-Build-Time'] = buildTime switch (request.operationId) { - case 'info': { - - response.data = { - version: "0.11.0", - activeServices: ["http", "kafka", "ldap", "mail", "mqtt", "websocket"], - search: { enabled: true } } - return - } - case 'services': - response.data = getServices(request.query['type']) - return - case 'serviceHttp': - const name = request.path['name'] - const service = httpServices.find(x => x.name === name) - if (service) { - response.data = { - name: service.name, - description: service.description, - version: service.version, - ...service.contact ? { contact: service.contact } : {}, - servers: service.servers, - paths: service.paths.map(p => { - return { - path: p.path, - ...p.summary ? { summary: p.summary } : {}, - ...p.description ? { description: p.description } : {}, - status: p.status, - ...p.errors ? { errors: p.errors } : {}, - operations: p.operations.map(o => { - return { - method: o.method, - ...o.summary ? {summary: o.summary} : {}, - ...o.description ? {description: o.description} : {}, - ...o.operationId ? { operationId: o.operationId } : {}, - deprecated: o.deprecated ?? false, - ...o.tags ? { tags: o.tags } : {}, - status: o.status, - ...o.errors ? { errors: o.errors } : {}, - metrics: { - http_requests_total: sum('http_requests_total', { name: 'service', value: service.name }, { name: 'endpoint', value: p.path}, { name: 'method', value: o.method.toUpperCase() }), - http_requests_errors_total: sum('http_requests_errors_total', { name: 'service', value: service.name }, { name: 'endpoint', value: p.path}, { name: 'method', value: o.method.toUpperCase() }), - http_request_timestamp: max('http_request_timestamp', { name: 'service', value: service.name }, { name: 'endpoint', value: p.path}, { name: 'method', value: o.method.toUpperCase() }) - } - } - }) - } - }), - } - } else{ - response.statusCode = 404 - } - return - case 'httpOperations': { - const name = request.path['name'] - const service = httpServices.find(x => x.name === name) - if (!service) { - response.statusCode = 404 - return - } - const method = request.query['method'] - const path = request.query['path'] - let operations = [] - for (const p of service.paths) { - if (!path || p.path === path) { - for (const o of p.operations) { - if (!method || o.method.toLowerCase() === method.toLowerCase()) { - operations.push({ - ...o, - metrics: { - http_requests_total: sum('http_requests_total', { name: 'service', value: service.name }, { name: 'endpoint', value: p.path}, { name: 'method', value: o.method.toUpperCase() }), - http_requests_errors_total: sum('http_requests_errors_total', { name: 'service', value: service.name }, { name: 'endpoint', value: p.path}, { name: 'method', value: o.method.toUpperCase() }), - http_request_timestamp: max('http_request_timestamp', { name: 'service', value: service.name }, { name: 'endpoint', value: p.path}, { name: 'method', value: o.method.toUpperCase() }) - } - }) - } - } - } - } - response.data = operations - return - } - case 'httpOpenApi': { - console.log(request) - const serviceName = request.path['name'] - const format = request.path['format'] - const service = httpServices.find(x => x.name === serviceName) - if (!service) { - response.statusCode = 404 - return - } - switch (format) { - case 'yaml': - response.headers['Content-Type'] = 'application/yaml' - response.data = service - return - case 'json': - response.headers['Content-Type'] = 'application/json' - response.data = service - return - default: - throw new Error(`Unsupported format: ${format}`) - } - } - case 'serviceKafka': - response.data = { - name: kafkaClusters[0].name, - description: kafkaClusters[0].description, - version: kafkaClusters[0].version, - contact: kafkaClusters[0].contact, - servers: kafkaClusters[0].servers, - topics: kafkaClusters[0].topics.map(x => { - return { - name: x.name, - ...x.summary === undefined ? {} : { summary: x.summary }, - metrics: { - kafka_messages_total: metrics.find(m => m.name === `kafka_messages_total{service="Kafka World",topic="${x.name}"}`).value, - kafka_message_timestamp: metrics.find(m => m.name === `kafka_message_timestamp{service="Kafka World",topic="${x.name}"}`).value - } - } - }), - groups: kafkaClusters[0].groups.map(x => { - return { - name: x.name, - generation: x.generation, - state: x.state, - protocol: x.protocol, - members: x.members.length ?? 0, - metrics: x.metrics - } - }), - clients: kafkaClusters[0].clients.map(c => { - return { - clientId: c.clientId, - address: c.address, - software: c.software, - } - }), - configs: kafkaClusters[0].configs.map(c => { - return { - id: c.id, - url: c.url, - provider: c.provider, - time: c.time - } - }) - } - return - case 'kafkaGroup': - response.data = kafkaClusters[0].groups.find(x => x.name === request.path['group']) - return case 'serviceMail': response.data = mailServices[0] return - case 'kafkaClusters': - response.data = kafkaClusters.map(x => getInfo(x, 'kafka')) - return - case 'kafkaTopics': - response.data = kafkaClusters[0].topics - return - case 'kafkaTopic': - const topic = kafkaClusters[0].topics.find(x => x.name === request.path.topic) - response.data = { - ...topic, - groups: kafkaClusters[0].groups.filter(g => g.topics.includes(topic.name)).map(x => { - return { - name: x.name, - generation: x.generation, - state: x.state, - protocol: x.protocol, - members: x.members.length ?? 0, - metrics: x.metrics - } - }), - } - if (response.data === undefined) { - response.statusCode = 404 - } - return - case 'kafkaProduceTopic': { - const topic = kafkaClusters[0].topics.find(x => x.name === request.path.topic) - if (!topic) { - response.statusCode = 404 - } else { - response.data = { - offsets: [ - { - partition: 0, - offset: 1234, - } - ] - } - } - return - } - case 'kafkaPartitions': { - const topic = kafkaClusters[0].topics.find(x => x.name === request.path.topic) - if (!topic) { - response.statusCode = 404 - return - } - response.data = topic.partitions - return - } - case 'kafkaPartition': { - const topic = kafkaClusters[0].topics.find(x => x.name === request.path.topic) - if (!topic) { - response.statusCode = 404 - return - } - const id = request.path.partitionId - if (id >= topic.partitions.length) { - response.statusCode = 404 - } else { - response.data = topic.partitions[id] - } - return - } - case 'kafkaProducePartition': { - const topic = kafkaClusters[0].topics.find(x => x.name === request.path.topic) - if (!topic) { - response.statusCode = 404 - return - } - const id = request.path.partitionId - if (id >= topic.partitions.length) { - response.statusCode = 404 - } else { - response.data = { - offsets: [ - { - partition: id, - offset: 1234, - } - ] - } - } - return - } - case 'kafkaOffsets': { - const topic = kafkaClusters[0].topics.find(x => x.name === request.path.topic) - if (!topic) { - response.statusCode = 404 - return - } - const id = request.path.partitionId - if (id >= topic.partitions.length) { - response.statusCode = 404 - } else { - const events = getEvents({topic: topic.name}).filter(x => x.data.partition === id) - if (request.header['Accept'] === 'application/json') { - response.data = events.map(x => ({ - key: x.data.key.value, - value: JSON.parse(x.data.message.value) - })) - } else { - response.data = events.map(x => ({ - key: base64.encode(x.data.key.value), - value: base64.encode(x.data.message.value) - })) - } - } - return - } - case 'kafkaOffset': { - const topic = kafkaClusters[0].topics.find(x => x.name === request.path.topic) - if (!topic) { - response.statusCode = 404 - return - } - const id = request.path.partitionId - if (id >= topic.partitions.length) { - response.statusCode = 404 - } else { - const events = getEvents({topic: topic.name}).filter(x => x.data.partition === id && x.data.offset === request.path.offset) - if (events.length === 0) { - response.statusCode = 404 - return - } - if (request.header['Accept'] === 'application/json') { - response.data = { - key: events[0].data.key.value, - value: JSON.parse(events[0].data.message.value) - } - } else { - response.data = { - key: base64.encode(events[0].data.key.value), - value: base64.encode(events[0].data.message.value) - } - } - } - return - } - case 'kafkaClient': - response.data = kafkaClusters[0].clients.find(x => x.clientId === request.path['clientId']) - return case 'mailboxes': response.data = mailServices[0].mailboxes return @@ -387,9 +85,6 @@ export default async function() { let type = request.path['type'] response.data = metrics.filter(x => x.name.startsWith(type + '_')) return - case 'events': - response.data = getEvents(request.query['traits']) - return case 'event': let id = request.path["id"] let e = getEvent(id) @@ -479,246 +174,6 @@ export default async function() { } } } - - jobs.push( - { - id: "8234", - traits: { - namespace: "job", - name: "cron", - }, - time: new Date(Date.now()).toISOString(), - data: { - schedule: '3s', - maxRuns: -1, - runs: 1, - nextRun: new Date(Date.now() + 60 * 60 * 1000).toISOString(), - duration: 2300, - tags: { - name: "cron", - file: "cron.js", - fileKey: "6f5cab53-9f51-4bbe-bb32-fe41ee71d542", - } - } - }, - { - id: "8134", - traits: { - namespace: "job", - name: "cron", - }, - time: new Date(Date.now() - 60 * 60 * 1000).toISOString(), - data: { - schedule: '3s', - maxRuns: 10, - runs: 1, - nextRun: new Date(Date.now()).toISOString(), - duration: 2300, - logs: [ - { - level: 'error', - message: 'an error message, see https://mokapi.io' - } - ], - error: { - message: 'There was an error' - }, - tags: { - name: "cron", - file: "cron.js", - fileKey: "6f5cab53-9f51-4bbe-bb32-fe41ee71d542", - } - }, - - }, - { - id: "81344", - traits: { - namespace: "job", - name: "cron", - }, - time: new Date(Date.now() - 60 * 60 * 1000).toISOString(), - data: { - schedule: '3s', - runs: 1, - nextRun: new Date(Date.now()).toISOString(), - duration: 2300, - logs: [ - { - level: 'warn', - message: 'a warning message, see https://mokapi.io' - } - ], - tags: { - name: "cron", - file: "cron.js", - fileKey: "6f5cab53-9f51-4bbe-bb32-fe41ee71d542", - } - }, - - } - ) -} - -function getServices(type) { - const services = [] - if (!type || type === 'http') { - services.push(...httpServices.map(x => { - return { - ...getInfo(x, 'http'), - metrics: { - http_requests_total: sum('http_requests_total', { name: 'service', value: x.name }), - http_requests_errors_total: sum('http_requests_errors_total', { name: 'service', value: x.name }), - http_request_timestamp: max('http_request_timestamp', { name: 'service', value: x.name }) - } - } - })) - } - if (!type || type === 'kafka') { - services.push(...kafkaClusters.map(x => { - return { - ...getInfo(x, 'kafka'), - metrics: { - kafka_messages_total: sum('kafka_messages_total', { name: 'service', value: x.name }), - kafka_message_timestamp: max('kafka_message_timestamp', { name: 'service', value: x.name }) - } - } - })) - } - if (!type || type === 'mail') { - services.push(...mailServices.map(x => { - return { - ...getInfo(x, 'mail'), - metrics: { - mail_mails_total: sum('mail_mails_total', { name: 'service', value: x.name }), - mail_mail_timestamp: max('mail_mail_timestamp', { name: 'service', value: x.name }) - } - } - })) - } - if (!type || type === 'ldap') { - services.push(...ldapServers.map(x => { - return { - ...getInfo(x, 'ldap'), - metrics: { - ldap_requests_total: sum('ldap_requests_total', { name: 'service', value: x.name }), - ldap_request_timestamp: max('ldap_request_timestamp', { name: 'service', value: x.name }) - } - } - })) - } - if (!type || type === 'mqtt') { - services.push(...mqttClusters.map(x => { - return { - ...getInfo(x, 'mqtt'), - metrics: { - mqtt_messages_total: sum('mqtt_messages_total', { name: 'service', value: x.name }), - mqtt_message_timestamp: max('mqtt_message_timestamp', { name: 'service', value: x.name }) - } - } - })) - } - if (!type || type === 'websocket') { - services.push(...websocketServices.map(x => { - return { - ...getInfo(x, 'websocket'), - metrics: { - websocket_messages_total: sum('websocket_messages_total', { name: 'service', value: x.name }), - websocket_message_timestamp: max('websocket_message_timestamp', { name: 'service', value: x.name }) - } - } - })) - } - - return services -} - -function getEvent(id) { - for (const e of httpEvents){ - if (e.id === id){ - return e - } - } - for (const e of kafkaEvents){ - if (e.id === id){ - return e - } - } - for (const e of mqttEvents){ - if (e.id === id){ - return e - } - } - for (const e of mailEvents){ - if (e.id === id){ - return e - } - } - for (const e of searches){ - if (e.id === id){ - return e - } - } - for (const e of jobs) { - if (e.id === id) { - return e - } - } - for (const e of websocketEvents) { - if (e.id === id) { - return e - } - } - return null -} - -function getEvents(traits) { - const result = [] - for (const e of httpEvents){ - if (matchEvent(e, traits)) { - result.push(e) - } - } - for (const e of kafkaEvents){ - if (matchEvent(e, traits)) { - result.push(e) - } - } - for (const e of mqttEvents){ - if (matchEvent(e, traits)) { - result.push(e) - } - } - for (const e of websocketEvents) { - if (matchEvent(e, traits)) { - result.push(e) - } - } - for (const e of mailEvents) { - if (matchEvent(e, traits)) { - result.push(e) - } - } - for (const e of searches) { - if (matchEvent(e, traits)) { - result.push(e) - } - } - for (const e of jobs) { - if (matchEvent(e, traits)) { - result.push(e) - } - } - return result -} - -function matchEvent(e, traits) { - for (let t in traits) { - if (e.traits[t] !== traits[t]) { - return false - } - } - return true } function getConfig(id) { @@ -742,26 +197,6 @@ function getConfigs() { ] } -function getInfo(config, type) { - const info = { - name: config.name, - description: config.description, - version: config.version, - type: type, - } - if (config.contact) { - info.contact = { - name: config.contact.name, - email: config.contact.email, - url: config.contact.url - } - } - if (config.status) { - info.status = config.status - } - return info -} - const jobConfig = { '6f5cab53-9f51-4bbe-bb32-fe41ee71d542': { id: '6f5cab53-9f51-4bbe-bb32-fe41ee71d542', diff --git a/webui/e2e/mocks/jobs.ts b/webui/e2e/mocks/jobs.ts new file mode 100644 index 000000000..47624c5ca --- /dev/null +++ b/webui/e2e/mocks/jobs.ts @@ -0,0 +1,78 @@ +export const jobs = [ + { + id: "8234", + traits: { + namespace: "job", + name: "cron", + }, + time: new Date(Date.now()).toISOString(), + data: { + schedule: '3s', + maxRuns: -1, + runs: 1, + nextRun: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + duration: 2300, + tags: { + name: "cron", + file: "cron.js", + fileKey: "6f5cab53-9f51-4bbe-bb32-fe41ee71d542", + } + } + }, + { + id: "8134", + traits: { + namespace: "job", + name: "cron", + }, + time: new Date(Date.now() - 60 * 60 * 1000).toISOString(), + data: { + schedule: '3s', + maxRuns: 10, + runs: 1, + nextRun: new Date(Date.now()).toISOString(), + duration: 2300, + logs: [ + { + level: 'error', + message: 'an error message, see https://mokapi.io' + } + ], + error: { + message: 'There was an error' + }, + tags: { + name: "cron", + file: "cron.js", + fileKey: "6f5cab53-9f51-4bbe-bb32-fe41ee71d542", + } + }, + + }, + { + id: "81344", + traits: { + namespace: "job", + name: "cron", + }, + time: new Date(Date.now() - 60 * 60 * 1000).toISOString(), + data: { + schedule: '3s', + runs: 1, + nextRun: new Date(Date.now()).toISOString(), + duration: 2300, + logs: [ + { + level: 'warn', + message: 'a warning message, see https://mokapi.io' + } + ], + tags: { + name: "cron", + file: "cron.js", + fileKey: "6f5cab53-9f51-4bbe-bb32-fe41ee71d542", + } + }, + + } +] \ No newline at end of file diff --git a/webui/e2e/mocks/kafka.ts b/webui/e2e/mocks/kafka.ts index fed386f8b..f6bee8b6f 100644 --- a/webui/e2e/mocks/kafka.ts +++ b/webui/e2e/mocks/kafka.ts @@ -1,5 +1,195 @@ -import { metrics, filter as filterMetrics, parseLabels } from './metrics.ts' +import { app } from 'mokapi' +import { metrics, filter as filterMetrics, parseLabels } from './metrics' import { base64 } from 'mokapi/encoding'; +import { getInfo, matchTraits } from './helpers'; + +export default function () { + app.http() + .operation('serviceKafka', (_, res) => { + res.data = { + name: clusters[0].name, + description: clusters[0].description, + version: clusters[0].version, + contact: clusters[0].contact, + servers: clusters[0].servers, + topics: clusters[0].topics.map(x => { + return { + name: x.name, + ...x.summary === undefined ? {} : { summary: x.summary }, + metrics: { + kafka_messages_total: metrics.find(m => m.name === `kafka_messages_total{service="Kafka World",topic="${x.name}"}`)?.value ?? 0, + kafka_message_timestamp: metrics.find(m => m.name === `kafka_message_timestamp{service="Kafka World",topic="${x.name}"}`)?.value ?? 0 + } + } + }), + groups: clusters[0].groups.map(x => { + return { + name: x.name, + generation: x.generation, + state: x.state, + protocol: x.protocol, + members: x.members.length ?? 0, + metrics: x.metrics + } + }), + clients: clusters[0].clients.map(c => { + return { + clientId: c.clientId, + address: c.address, + software: c.software, + } + }), + configs: clusters[0].configs.map(c => { + return { + id: c.id, + url: c.url, + provider: c.provider, + time: c.time + } + }) + } + }) + .operation('kafkaGroup', (req, res) => { + res.data = clusters[0].groups.find(x => x.name === req.path['group']) + }) + .operation('kafkaClusters', (req, res) => { + res.data = clusters.map(x => getInfo(x, 'kafka')) + }) + .operation('kafkaTopics', (req, res) => { + res.data = clusters[0].topics + }) + .operation('kafkaTopic', (req, res) => { + const topic = clusters[0].topics.find(x => x.name === req.path.topic) + if (!topic) { + res.rebuild(404) + return + } + res.data = { + ...topic, + groups: clusters[0].groups.filter(g => g.topics.includes(topic.name)).map(x => { + return { + name: x.name, + generation: x.generation, + state: x.state, + protocol: x.protocol, + members: x.members.length ?? 0, + metrics: x.metrics + } + }), + } + }) + .operation('kafkaProduceTopic', (req, res) => { + const topic = clusters[0].topics.find(x => x.name === req.path.topic) + if (!topic) { + res.rebuild(404) + return + } + res.data = { + offsets: [ + { + partition: 0, + offset: 1234, + } + ] + } + }) + .operation('kafkaPartitions', (req, res) => { + const topic = clusters[0].topics.find(x => x.name === req.path.topic) + if (!topic) { + res.rebuild(404) + return + } + res.data = topic.partitions + }) + .operation('kafkaPartition', (req, res) => { + const topic = clusters[0].topics.find(x => x.name === req.path.topic) + if (!topic) { + res.rebuild(404) + return + } + const id = req.path.partitionId + if (id >= topic.partitions.length) { + res.rebuild(404) + return + } + res.data = topic.partitions[id] + }) + .operation('kafkaProducePartition', (req, res) => { + const topic = clusters[0].topics.find(x => x.name === req.path.topic) + if (!topic) { + res.rebuild(404) + return + } + const id = req.path.partitionId + if (id >= topic.partitions.length) { + res.rebuild(404) + return + } + res.data = { + offsets: [ + { + partition: id, + offset: 1234, + } + ] + } + }) + .operation('kafkaOffsets', (req, res) => { + const topic = clusters[0].topics.find(x => x.name === req.path.topic) + if (!topic) { + res.rebuild(404) + return + } + const id = req.path.partitionId + if (id >= topic.partitions.length) { + res.rebuild(404) + return + } + const data = events.filter(x => matchTraits(x, {topic: topic.name, type: 'message'}) && x.data.partition === id) + if (req.header['Accept'] === 'application/json') { + res.data = data.map(x => ({ + key: x.data.key?.value, + value: JSON.parse(x.data.message?.value ?? '') + })) + } else { + res.data = data.map(x => ({ + key: base64.encode(x.data.key?.value ?? ''), + value: base64.encode(x.data.message?.value ?? '') + })) + } + }) + .operation('kafkaOffset', (req, res) => { + const topic = clusters[0].topics.find(x => x.name === req.path.topic) + if (!topic) { + res.rebuild(404) + return + } + const id = req.path.partitionId + if (id >= topic.partitions.length) { + res.rebuild(404) + return + } + const data = events.filter(x => matchTraits(x, {topic: topic.name, type: 'message'}) && x.data.partition === id && x.data.offset === req.path.offset) + if (data.length === 0) { + res.rebuild(404) + return + } + if (req.header['Accept'] === 'application/json') { + res.data = { + key: events[0].data.key?.value, + value: JSON.parse(events[0].data.message?.value ?? '') + } + } else { + res.data = { + key: base64.encode(data[0].data.key?.value ?? ''), + value: base64.encode(data[0].data.message?.value ?? '') + } + } + }) + .operation('kafkaClient', (req, res) => { + res.data = clusters[0].clients.find(x => x.clientId === req.path['clientId']) + }) +} const Product = { type: 'object', @@ -428,5 +618,33 @@ export let events = [ partition: 0, messageId: 'avro' } + }, + { + id: '84490626-b806-4420-898c-d6b28d1f4ab1', + traits: { + namespace: 'kafka', + name: 'Kafka World', + type: 'request', + clientId: 'consumer', + }, + time: '2025-02-13T09:49:25.482366+01:00', + data: { + "api": "Kafka World", + "header": { + "requestKey": 10, + "requestName": "FindCoordinator", + "version": 2 + }, + "request": { + "key": "mokapi.shop.products", + "keyType": 0 + }, + "response": { + "errorCode": "", + "errorMessage": "", + "host": "localhost", + "port": 9092 + } + } } ] diff --git a/webui/e2e/mocks/kafka.yml b/webui/e2e/mocks/kafka.yml index c475d4d69..e4886886f 100644 --- a/webui/e2e/mocks/kafka.yml +++ b/webui/e2e/mocks/kafka.yml @@ -601,3 +601,40 @@ components: type: string description: type: string + KafkaEvent: + type: object + properties: + offset: + type: integer + key: + $ref: '#/components/schemas/KafkaValue' + message: + $ref: '#/components/schemas/KafkaValue' + schemaId: + type: integer + partition: + type: integer + headers: + type: object + additionalProperties: + type: object + properties: + value: + type: string + binary: + type: string + deleted: + type: boolean + producerId: + type: number + producerEpoch: + type: number + sequenceNumber: + type: number + KafkaValue: + type: object + properties: + value: + type: string + binary: + type: string diff --git a/webui/e2e/mocks/ldap.js b/webui/e2e/mocks/ldap.ts similarity index 100% rename from webui/e2e/mocks/ldap.js rename to webui/e2e/mocks/ldap.ts diff --git a/webui/e2e/mocks/mail.js b/webui/e2e/mocks/mail.ts similarity index 94% rename from webui/e2e/mocks/mail.js rename to webui/e2e/mocks/mail.ts index 31caf47a4..efaf2ac4b 100644 --- a/webui/e2e/mocks/mail.js +++ b/webui/e2e/mocks/mail.ts @@ -111,12 +111,13 @@ export let mailEvents = [ from: 'alice@mokapi.io', to: ['bob@mokapi.io'], messageId: '20230223-084925.763-4196@mokapi.io', - subject: 'A test mail' + subject: 'A test mail', + duration: 30016, }, }, ] -export function getMail(messageId) { +export function getMail(messageId: string) { for (let m of mails) { if (m.messageId === messageId) { return m @@ -125,8 +126,12 @@ export function getMail(messageId) { return null } -export function getAttachment(messageId, attachmentName) { +export function getAttachment(messageId: string, attachmentName: string) { const mail = getMail(messageId) + if (!mail) { + return null + } + for (let a of mail.attachments) { if (a.name === attachmentName) { return a diff --git a/webui/e2e/mocks/metrics.ts b/webui/e2e/mocks/metrics.ts index 466918a84..466bc7a05 100644 --- a/webui/e2e/mocks/metrics.ts +++ b/webui/e2e/mocks/metrics.ts @@ -134,7 +134,7 @@ export let metrics = [ }, ] -export function sum(name, ...labels){ +export function sum(name: string, ...labels: Label[]){ if (!metrics){ return 0 } @@ -152,7 +152,7 @@ export function sum(name, ...labels){ return sum } -export function max(name, ...labels) { +export function max(name: string, ...labels: Label[]) { if (!metrics){ return 0 } @@ -173,7 +173,7 @@ export function max(name, ...labels) { return max } -export function filter(name, ...labels) { +export function filter(name: string, ...labels: Label[]) { const result = []; if (!metrics){ return []; @@ -191,7 +191,7 @@ export function filter(name, ...labels) { return result; } -export function parseLabels(metric) { +export function parseLabels(metric: { name: string }) { const match = metric.name.match(/\{(.+)}/); if (!match || match.length < 2 || !match[1]) { return {}; @@ -205,7 +205,7 @@ export function parseLabels(metric) { ); } -function matchLabels(metric, labels){ +function matchLabels(metric: { name: string }, labels: Label[]){ for (var label of labels){ const s = `${label.name}="${label.value}"` if (!metric.name.includes(s)){ diff --git a/webui/e2e/mocks/mqtt.js b/webui/e2e/mocks/mqtt.ts similarity index 94% rename from webui/e2e/mocks/mqtt.js rename to webui/e2e/mocks/mqtt.ts index 4826f8e72..e31098050 100644 --- a/webui/e2e/mocks/mqtt.js +++ b/webui/e2e/mocks/mqtt.ts @@ -1,4 +1,4 @@ -import { metrics } from 'metrics.ts'; +import { metrics } from './metrics'; import { on } from 'mokapi'; const Measurement = { @@ -106,14 +106,14 @@ export default async function () { topics: clusters[0].topics.map(x => { return { name: x.name, - ...x.title ? { title: x.title } : {}, - ...x.summary ? { summary: x.summary } : {}, - ...x.description ? { description: x.description } : {}, + ...('title' in x ? { title: x.title } : {}), + ...('summary' in x ? { summary: x.summary } : {}), + ...('description' in x ? { description: x.description } : {}), messages: x.messages, - ...x.instances ? { instances: x.instances } : {}, + ...('instances' in x ? { instances: x.instances } : {}), metrics: { - mqtt_messages_total: metrics.find(m => m.name === `mqtt_messages_total{service="${clusters[0].name}",topic="${x.name}"}`).value, - mqtt_message_timestamp: metrics.find(m => m.name === `mqtt_message_timestamp{service="${clusters[0].name}",topic="${x.name}"}`).value + mqtt_messages_total: metrics.find(m => m.name === `mqtt_messages_total{service="${clusters[0].name}",topic="${x.name}"}`)?.value ?? 0, + mqtt_message_timestamp: metrics.find(m => m.name === `mqtt_message_timestamp{service="${clusters[0].name}",topic="${x.name}"}`)?.value ?? 0 } } }), diff --git a/webui/e2e/mocks/tsconfig.json b/webui/e2e/mocks/tsconfig.json new file mode 100644 index 000000000..2d8745e61 --- /dev/null +++ b/webui/e2e/mocks/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "lib": ["es2020", "dom"], + "paths": { + "mokapi": ["../../../npm/go-mokapi/types/index"], + "mokapi/*": ["../../../npm/go-mokapi/types/*"] + }, + "types": ["../../../npm/go-mokapi/types/global"] + } +} \ No newline at end of file diff --git a/webui/e2e/mocks/types.d.ts b/webui/e2e/mocks/types.d.ts new file mode 100644 index 000000000..439c31afb --- /dev/null +++ b/webui/e2e/mocks/types.d.ts @@ -0,0 +1,36 @@ +interface Config { + name: string; + description?: string; + version?: string; + contact?: Contact; + status?: string; +} + +interface Info { + type: string; + name: string; + description?: string; + version?: string; + contact?: Contact; + status?: string; +} + +interface Contact { + name?: string; + email?: string; + url?: string; +} + +interface Label { + name: string; + value: string; +} + +type Traits = Record; + +interface ServiceEvent { + id: string; + traits: Traits; + time: string; + data: any +} \ No newline at end of file diff --git a/webui/e2e/mocks/websocket.js b/webui/e2e/mocks/websocket.ts similarity index 88% rename from webui/e2e/mocks/websocket.js rename to webui/e2e/mocks/websocket.ts index f4ede3e06..f65d028fe 100644 --- a/webui/e2e/mocks/websocket.js +++ b/webui/e2e/mocks/websocket.ts @@ -1,4 +1,4 @@ -import { metrics } from 'metrics.ts'; +import { metrics } from './metrics'; import { on } from 'mokapi'; const ChatMessage = { @@ -99,11 +99,11 @@ export default async function () { channels: services[0].channels.map((x) => { return { name: x.name, - ...(x.title ? { title: x.title } : {}), - ...(x.summary ? { summary: x.summary } : {}), - ...(x.description ? { description: x.description } : {}), + ...('title' in x ? { title: x.title } : {}), + ...('summary' in x ? { summary: x.summary } : {}), + ...('description' in x ? { description: x.description } : {}), messages: x.messages, - ...x.instances ? { instances: x.instances } : {}, + ...('instances' in x ? { instances: x.instances } : {}), metrics: { websocket_messages_total: metrics.find( (m) => m.name === `websocket_messages_total{service="${services[0].name}",channel="${x.name}"}` @@ -230,4 +230,27 @@ export let events = [ ] }, }, + { + id: '9ab700fa-55b6-4c32-9c53-8cd3926dacc4', + traits: { + namespace: 'websocket', + name: 'WebSocket Chat API', + channel: '/chats/{chatId}', + type: 'connection', + clientId: clients[0].id, + }, + time: '2026-02-13T09:50:26.100000+01:00', + data: { + type: 'connect', + channel: '/chats/general', + client: { + id: clients[1].id, + query: {}, + header: {}, + address: clients[0].address, + server: clients[0].serverAddress + }, + api: 'WebSocket Chat API', + }, + }, ]; \ No newline at end of file diff --git a/webui/e2e/tests/components/dashboard.ts b/webui/e2e/tests/components/dashboard.ts index d2dd6a066..757cbd8f7 100644 --- a/webui/e2e/tests/components/dashboard.ts +++ b/webui/e2e/tests/components/dashboard.ts @@ -15,6 +15,7 @@ export function useDashboardTabs(page: Page) { kafka: dashboard.getByRole('link', { name: 'Kafka', exact: true }), mail: dashboard.getByRole('link', { name: 'Mail', exact: true }), ldap: dashboard.getByRole('link', { name: 'LDAP', exact: true }), + jobs: dashboard.getByRole('link', { name: 'Jobs', exact: true }), configs: dashboard.getByRole('link', { name: 'Configs', exact: true }), } } \ No newline at end of file diff --git a/webui/e2e/tests/dashboard/http/filter.spec.ts b/webui/e2e/tests/dashboard/http/filter.spec.ts new file mode 100644 index 000000000..830d5a3e2 --- /dev/null +++ b/webui/e2e/tests/dashboard/http/filter.spec.ts @@ -0,0 +1,162 @@ +import { useDashboard } from '../../components/dashboard' +import { test, expect } from '../../models/fixture-dashboard' + +test.describe('Visit HTTP dashboard', () => { + test.use({ colorScheme: 'dark' }) + + test('Verifiy HTTP filters', async ({ page }) => { + const { tabs, open } = useDashboard(page) + await open() + await tabs.http.click() + + const table = page.getByRole('table', { name: 'Recent Requests' }); + let rows = table.locator('tbody tr'); + await expect(rows).toHaveCount(5) + + const filterButton = page.getByRole('button', { name: 'Filter' }) + const dialog = page.getByRole('dialog', { name: 'Filter' }) + const resetFilters = async() => { + await page.evaluate(() => { + localStorage.removeItem('http-requests-filter'); + }); + await page.reload() + } + + await test.step('Verify API names', async() => { + await resetFilters() + + await filterButton.click() + + await dialog.getByRole('checkbox', { name: 'API Name' }).check() + const names = dialog.getByRole('combobox', { name: 'Service' }) + const options = names.getByRole('option') + await expect(options).toHaveCount(2) + + await names.selectOption('Books API') + await expect(rows).toHaveCount(0) + + await names.selectOption('Swagger Petstore') + await expect(rows).toHaveCount(5) + + await dialog.getByRole('button', { name: 'Close' }).click() + }) + + await test.step('Verify HTTP method', async() => { + await resetFilters() + + await filterButton.click() + + await dialog.getByRole('checkbox', { name: 'Method' }).check() + await dialog.getByRole('combobox', { name: 'Method' }).selectOption('POST') + + await dialog.getByRole('button', { name: 'Close' }).click() + await expect(filterButton).toContainText('1') + + await expect(rows).toHaveCount(2) + }) + + await test.step('Verify URL', async() => { + await resetFilters() + + await filterButton.click() + + await dialog.getByRole('checkbox', { name: 'URL' }).check() + await dialog.getByRole('textbox', { name: 'URL' }).fill('[sS]tatus') + + await expect(rows).toHaveCount(2) + + await dialog.getByRole('button', { name: 'Close' }).click() + await expect(filterButton).toContainText('1') + + await expect(rows).toHaveCount(2) + }) + + await test.step('Verify request header', async() => { + await resetFilters() + + await filterButton.click() + + await dialog.getByRole('checkbox', { name: 'Request Header' }).check() + await dialog.getByRole('textbox', { name: 'Request Header Name 1' }).fill('LongHeader') + await dialog.getByRole('textbox', { name: 'Request Header Value 1' }).fill('Lorem') + + await expect(rows).toHaveCount(1) + + await dialog.getByRole('checkbox', { name: 'Request Header' }).check() + await dialog.getByRole('textbox', { name: 'Request Header Name 1' }).fill('debug') + await dialog.getByRole('textbox', { name: 'Request Header Value 1' }).fill('bar') + + await dialog.getByRole('textbox', { name: 'Request Header Name 1' }).fill('noSpec') + await dialog.getByRole('textbox', { name: 'Request Header Value 1' }).fill('bar') + + await expect(rows).toHaveCount(1) + + await dialog.getByRole('button', { name: 'Close' }).click() + await expect(filterButton).toContainText('1') + }) + + await test.step('Verify response status code', async() => { + await resetFilters() + + await filterButton.click() + + await dialog.getByRole('checkbox', { name: 'Status Code' }).check() + await dialog.getByRole('textbox', { name: 'Status Code' }).fill('200') + await expect(rows).toHaveCount(1) + + await dialog.getByRole('textbox', { name: 'Status Code' }).fill('200-400') + await expect(rows).toHaveCount(3) + + await dialog.getByRole('textbox', { name: 'Status Code' }).fill('2xx') + await expect(rows).toHaveCount(3) + + await dialog.getByRole('textbox', { name: 'Status Code' }).fill('!2xx') + await expect(rows).toHaveCount(2) + + await dialog.getByRole('button', { name: 'Close' }).click() + await expect(filterButton).toContainText('1') + }) + + await test.step('Verify response header', async() => { + await resetFilters() + + await filterButton.click() + + await dialog.getByRole('checkbox', { name: 'Response Header' }).check() + + await dialog.getByRole('textbox', { name: 'Response Header Name 1' }).fill('Content-Type') + await dialog.getByRole('textbox', { name: 'Response Header Value 1' }).fill('application/json') + await expect(rows).toHaveCount(5) + + await resetFilters() + await filterButton.click() + await dialog.getByRole('checkbox', { name: 'Response Header' }).check() + + await dialog.getByRole('textbox', { name: 'Response Header Name 1' }).fill('Set-Cookie') + await expect(rows).toHaveCount(2) + + await dialog.getByRole('textbox', { name: 'Response Header Value 1' }).fill('38afes6v3') + await expect(rows).toHaveCount(1) + + await dialog.getByRole('button', { name: 'Close' }).click() + await expect(filterButton).toContainText('1') + }) + + await test.step('Verify client IP', async() => { + await resetFilters() + + await filterButton.click() + + await dialog.getByRole('checkbox', { name: 'Client IP' }).check() + + await dialog.getByRole('textbox', { name: 'Client IP' }).fill('127.0.0.1') + await expect(rows).toHaveCount(4) + + await dialog.getByRole('textbox', { name: 'Client IP' }).fill('192.0.1.1/24') + await expect(rows).toHaveCount(1) + + await dialog.getByRole('button', { name: 'Close' }).click() + await expect(filterButton).toContainText('1') + }) + }) +}) \ No newline at end of file diff --git a/webui/e2e/tests/dashboard/jobs.spec.ts b/webui/e2e/tests/dashboard/jobs.spec.ts new file mode 100644 index 000000000..e9ef42461 --- /dev/null +++ b/webui/e2e/tests/dashboard/jobs.spec.ts @@ -0,0 +1,16 @@ +import { useDashboard } from '../components/dashboard' +import { test, expect } from '../models/fixture-dashboard' + +test.describe('Visit Jobs dashboard', () => { + test.use({ colorScheme: 'dark' }) + + test('Verifiy Job table', async ({ page }) => { + const { tabs, open } = useDashboard(page) + await open() + await tabs.jobs.click() + + const table = page.getByRole('table', { name: 'Recent Jobs' }); + let rows = table.locator('> tbody > tr:not(.collapse-row)'); + await expect(rows).toHaveCount(3) + }) +}) \ No newline at end of file diff --git a/webui/e2e/tests/dashboard/websocket/overview.spec.ts b/webui/e2e/tests/dashboard/websocket/overview.spec.ts index cc21392a9..5fd5afcce 100644 --- a/webui/e2e/tests/dashboard/websocket/overview.spec.ts +++ b/webui/e2e/tests/dashboard/websocket/overview.spec.ts @@ -81,15 +81,24 @@ test('Visit Websocket overview', async ({ page }) => { await expect(info.getByLabel('Server')).toHaveText('localhost:8080'); await expect(info.getByLabel('Type of API')).toHaveText('Websocket'); - const messages = page.getByRole('table', { name: 'Messages' }); - const rows = messages.locator('tbody tr'); + const tblMessages = page.getByRole('table', { name: 'Messages' }); + const messages = tblMessages.locator('tbody tr'); + await expect(messages).toHaveCount(1); + + const tblRequests = page.getByRole('table', { name: 'Requests' }); + const requests = tblRequests.locator('tbody tr'); await expect(rows).toHaveCount(1); + await expect(await getCellByColumnName(tblRequests, 'Type', requests.nth(0))).toHaveText('Connect') + await expect(await getCellByColumnName(tblRequests, 'Channel', requests.nth(0))).toHaveText('/chats/general') + await expect(await getCellByColumnName(tblRequests, 'Reason', requests.nth(0))).toHaveText('') + await expect(await getCellByColumnName(tblRequests, 'Closed By', requests.nth(0))).toHaveText('') + await expect(await getCellByColumnName(tblRequests, 'Time', requests.nth(0))).not.toBeEmpty() - await rows.nth(0).click() + await messages.nth(0).click() }) - await meta.getByLabel('Channel').click(); + await page.getByRole('region', { name: 'Meta' }).getByLabel('Channel').click(); await page.getByRole('region', { name: 'Info' }).getByLabel('Cluster').click(); }) diff --git a/webui/lib/events.ts b/webui/lib/events.ts new file mode 100644 index 000000000..c1730f949 --- /dev/null +++ b/webui/lib/events.ts @@ -0,0 +1,164 @@ +interface Event { + id: string; + traits: Traits; + time: string; + data: any +} + +interface Label { + name: string + value: string +} + +export interface Query { + labels: Label[] + http?: HttpEventFilter +} + +export interface HttpEventFilter { + methods?: string[] + url?: string + requestHeaders?: { name: string, value: string }[] + statusCode?: string + responseHeaders?: { name: string, value: string }[] + clientIP?: string +} + +export function match(e: Event, q?: Query): boolean { + if (!q) { + return true + } + + if (!matchTraits(e, q.labels)) { + return false + } + if (q.http && e.traits.namespace === 'http') { + console.log(q.http.methods, e) + if (q.http.methods) { + if (!q.http.methods.includes(e.data.request.method)) { + return false + } + } + if (q.http.url) { + if (!e.data.request.url.match(q.http.url)) { + return false + } + } + if (q.http.requestHeaders) { + if (!e.data.request.parameters) { + return false + } + for (const h of q.http.requestHeaders) { + const found = e.data.request.parameters.find((x: any) => x.type === 'header' && x.name === h.name) + if (!found) { + return false + } + if (h.value !== '' && (!found.raw || !found.raw.match(h.value))) { + return false + } + } + } + if (q.http.responseHeaders) { + if (!e.data.response.headers) { + return false + } + for (const h of q.http.responseHeaders) { + const found = e.data.response.headers[h.name] + if (!found) { + return false + } + if (h.value !== '' && !found.match(h.value)) { + return false + } + } + } + if (q.http.statusCode) { + const values = q.http.statusCode.split(',').map((x: string) => x.trim()) as string[] + const statusCode = e.data.response.statusCode + for (let val of values) { + let not = false + let match = false + if (val[0] === '!') { + not = true + val = val.substring(1) + } + + if (val.length === 3 && val.includes('xx')) { + val = `${val[0]}00-${val[0]}99` + } + + if (val.includes('-')) { + const parts = val.split('-').map(x => x.trim()) + if (parts.length != 2) { + continue + } + const min = parseInt(parts[0]) + const max = parseInt(parts[1]) + match = statusCode >= min && statusCode <= max + } else { + match = statusCode.toString() === val + } + + if (not === match) { + return false + } + } + } + if (q.http.clientIP) { + const values = q.http.clientIP.split(',').map((x: string) => x.trim()) as string[] + for (let val of values) { + let not = false + let match = false + if (val[0] === '!') { + not = true + val = val.substring(1) + } + + if (val.includes('/')) { + match = cidrMatch(e.data.clientIP, val) + } else { + match = e.data.clientIP === val + } + + if (not === match) { + return false + } + } + } + } + return true +} + +export function matchTraits(e: ServiceEvent, labels: Label[]) { + for (let label of labels) { + if (e.traits[label.name] !== label.value) { + return false + } + } + return true +} + +function cidrMatch(ip: string, cidr: string): boolean { + const parts: string[] = cidr.split('/'); + if (parts.length !== 2) { + return false + } + + const [range, bitsStr] = parts; + const bits: number = parseInt(bitsStr!, 10); + + if (isNaN(bits) || bits < 0 || bits > 32) { + return false + } + + const mask: number = ~(2 ** (32 - bits) - 1); + + return (ipToInt(ip) & mask) === (ipToInt(range!) & mask); +} + +function ipToInt(ip: string): number { + return ip + .split('.') + .map((octet) => parseInt(octet, 10)) + .reduce((acc, octet) => (acc << 8) + octet, 0); +} \ No newline at end of file diff --git a/webui/scripts/dashboard-demo/collect-dashboard.ts b/webui/scripts/dashboard-demo/collect-dashboard.ts index 09767dc95..708ce165f 100644 --- a/webui/scripts/dashboard-demo/collect-dashboard.ts +++ b/webui/scripts/dashboard-demo/collect-dashboard.ts @@ -25,7 +25,7 @@ export async function collectDashboard() { 'service_HR Employee Directory': { path: '/api/services/ldap/HR%20Employee%20Directory', loader: loadJson }, 'service_Smart Home MQTT API': { path: '/api/services/mqtt/Smart%20Home%20MQTT%20API', loader: loadJson }, 'service_WebSocket Chat API': { path: '/api/services/websocket/WebSocket%20Chat%20API', loader: loadJson }, - events: { path: '/api/events', loader: fetchEvents }, + events: { path: '/api/events?size=1000', loader: fetchEvents }, 'mailbox_alice.johnson@example.com': { path: '/api/services/mail/Mail%20Server/mailboxes/alice.johnson@example.com', loader: loadJson }, 'mailbox_bob.miller@example.com': { path: '/api/services/mail/Mail%20Server/mailboxes/bob.miller@example.com', loader: loadJson }, configs: { path: '/api/configs', loader: loadConfigs }, @@ -73,11 +73,14 @@ async function directoryExists(path: string) { } async function fetchEvents(url: string, key: string, snapshot: Record) { - const events = await fetchJson(url); - snapshot[key] = events + const page = await fetchJson(url); + snapshot[key] = page.items const mails = []; - for (const event of events) { + for (const event of page.items) { + const detail = await fetchJson(`${baseUrl}/api/events/${event.id}`); + snapshot['event_'+event.id] = detail + if (event.traits.namespace !== 'mail') { continue; } diff --git a/webui/scripts/dashboard-demo/demo-configs/tsconfig.json b/webui/scripts/dashboard-demo/demo-configs/tsconfig.json new file mode 100644 index 000000000..c2ff43631 --- /dev/null +++ b/webui/scripts/dashboard-demo/demo-configs/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "lib": ["es2020", "dom"], + "paths": { + "mokapi": ["../../../../npm/go-mokapi/types/index"], + "mokapi/*": ["../../../../npm/go-mokapi/types/*"] + }, + "types": ["../../../../npm/go-mokapi/types/global"] + } +} \ No newline at end of file diff --git a/webui/scripts/dashboard-demo/demo-configs/websocket.ts b/webui/scripts/dashboard-demo/demo-configs/websocket.ts index 11e544199..4e355865c 100644 --- a/webui/scripts/dashboard-demo/demo-configs/websocket.ts +++ b/webui/scripts/dashboard-demo/demo-configs/websocket.ts @@ -2,9 +2,11 @@ import { on } from 'mokapi' export default function() { on('websocket', (event) => { - event.broadcast({ - from: event.message.username, - text: event.message.text - }) + if (event.type === 'message') { + event.broadcast({ + from: event.message.username, + text: event.message.text + }) + } }, { track: true }) } \ No newline at end of file diff --git a/webui/src/assets/datatable.css b/webui/src/assets/datatable.css index 8c4ed6bd6..3312daa80 100644 --- a/webui/src/assets/datatable.css +++ b/webui/src/assets/datatable.css @@ -92,4 +92,12 @@ table.dataTable button.row-link { .cell-link:hover::after { opacity: 0.8; +} + +.pagination .page-link { + cursor: pointer; + font-size: 12px; +} +.pagination .page-item:not(.active) .page-link { + color: var(--link-color) } \ No newline at end of file diff --git a/webui/src/components/dashboard/Events.vue b/webui/src/components/dashboard/Events.vue deleted file mode 100644 index 03197b434..000000000 --- a/webui/src/components/dashboard/Events.vue +++ /dev/null @@ -1,85 +0,0 @@ - - - \ No newline at end of file diff --git a/webui/src/components/dashboard/JobCard.vue b/webui/src/components/dashboard/JobCard.vue index 4e295aad0..51e2fa8d8 100644 --- a/webui/src/components/dashboard/JobCard.vue +++ b/webui/src/components/dashboard/JobCard.vue @@ -1,14 +1,18 @@ + + \ No newline at end of file diff --git a/webui/src/components/dashboard/Search.vue b/webui/src/components/dashboard/Search.vue index cb1d8e5d1..b9fadb8ca 100644 --- a/webui/src/components/dashboard/Search.vue +++ b/webui/src/components/dashboard/Search.vue @@ -10,13 +10,13 @@ import Event from './search/Event.vue'; import Ldap from './search/Ldap.vue'; import Mail from './search/Mail.vue'; import Config from './search/Config.vue'; +import Pager from './Pager.vue'; const route = useRoute() const queryText = ref(route.query.q?.toString() ?? '') const pageIndex = ref(getIndex()) const errorMessage = ref() const searchResult = ref(); -const maxVisiblePages = 10 // max pages in pagination const showTips = ref(false) const facets = ref<{ [name: string]: string | undefined }>({}) const loading = useProgressiveLoading() @@ -27,24 +27,7 @@ const pageNumber = computed(() => { } return Math.ceil(searchResult.value?.total / 10) }) -const pageRange = computed(() => { - const half = Math.floor(maxVisiblePages / 2) - let start = pageIndex.value + 1 - half - let end = pageIndex.value + 1 + half - 1 - - if (start < 1) { - start = 1 - end = maxVisiblePages - } - - if (end > pageNumber.value) { - end = pageNumber.value - start = Math.max(1, pageNumber.value - maxVisiblePages + 1) - } - - return Array.from({ length: end - start + 1 }, (_, i) => start + i) -}) let timeout: ReturnType; watch(queryText, async () => { if (!queryText.value || queryText.value.length < 3 || queryText.value.endsWith(':')) { @@ -338,23 +321,7 @@ function facetTitle(s: string) {
- +
@@ -397,14 +364,6 @@ function facetTitle(s: string) { margin-top: 15px; } -.pagination .page-link { - cursor: pointer; -} - -.pagination .page-item:not(.active) .page-link { - color: var(--link-color) -} - .dashboard .search-results .card { border: 1px solid var(--card-border); border-radius: 0.75rem; diff --git a/webui/src/components/dashboard/Tabs.vue b/webui/src/components/dashboard/Tabs.vue index 15492a253..75da2bb89 100644 --- a/webui/src/components/dashboard/Tabs.vue +++ b/webui/src/components/dashboard/Tabs.vue @@ -24,7 +24,6 @@ const tabItems = computed(() => [ { text: 'Mail', isVisible: isServiceAvailable('mail'), to: { name: getRouteName('mail').value } }, { text: 'LDAP', isVisible: isServiceAvailable('ldap'), to: { name: getRouteName('ldap').value } }, { text: 'Jobs', isVisible: hasJobs.value, to: { name: getRouteName('jobs').value } }, - // { text: 'Events', isVisible: true, to: { name: getRouteName('events').value } }, { text: 'Configs', isVisible: true, to: { name: getRouteName('configs').value } }, { text: 'Faker', isVisible: getMode() === 'live', to: { name: getRouteName('tree').value } }, { text: 'Search', isVisible: props.appInfo.search.enabled && getMode() !== 'demo', to: { name: getRouteName('search').value } }, diff --git a/webui/src/components/dashboard/http/Request.vue b/webui/src/components/dashboard/http/Request.vue index 74682e62d..b14146d7f 100644 --- a/webui/src/components/dashboard/http/Request.vue +++ b/webui/src/components/dashboard/http/Request.vue @@ -6,28 +6,26 @@ import HttpBody from './HttpBody.vue' import HttpHeader from './HttpEventHeader.vue' import Loading from '@/components/Loading.vue' import Message from '@/components/Message.vue' -import { onUnmounted, computed, onMounted, ref, watch } from 'vue' +import { computed, ref, watch } from 'vue' import Actions from '../Actions.vue' import { useDashboard } from '@/composables/dashboard' -import { useMeta } from '@/composables/meta' -import type { EventResult, EventsResult } from '@/types/dashboard' const route = useRoute(); -const { dashboard, getMode } = useDashboard() +const { dashboard } = useDashboard() -const data = ref(null); -const events = ref(null); +const event = ref(null); +const isLoading = ref(true) const operation = computed(() => { - if (!data.value || !data.value?.event) { + if (!event.value) { return undefined; } - const serviceName = data.value?.event?.traits.name + const serviceName = event.value.traits.name if (!serviceName) { return undefined } - const path = data.value.event.traits.path; - const method = data.value.event.traits.method?.toLocaleLowerCase(); + const path = event.value.traits.path; + const method = event.value.traits.method?.toLocaleLowerCase(); const result = dashboard.value.getHttpOperations(serviceName, path, method) if (!result.operations.value || result.operations.value.length === 0) { return undefined @@ -35,48 +33,25 @@ const operation = computed(() => { return result.operations.value[0] }) -const eventId = computed(() => { - const id = route.params.id - if (!id) { - return undefined - } - - if (typeof id === 'string') { - if (isNumber(id)) { - const index = parseFloat(id); - const ev = events.value?.events[index]; - return ev?.id ?? null; - } else { - return id; - } - } - return null -}) - watch(() => dashboard.value, (db, _, onCleanup) => { - if (!eventId.value) { - return - } - const res1 = db.getEvent(eventId.value); - data.value = res1; + const res = db.getEvent(route.params.id as string); - const res2 = db.getEvents({ name: 'namespace', value: 'http' }); - events.value = res2; + const stop = watch(res.event, (v) => { event.value = v; isLoading.value = false }, { immediate: true }) onCleanup(() => { - res1.close(); - res2.close(); + stop() + res.close }); }, { immediate: true } ); const eventData = computed(() => { - if (!data.value || !data.value.event) { + if (!event.value) { return undefined; } - return data.value.event?.data + return event.value.data }) function getResponseContentType(): string { @@ -91,29 +66,12 @@ const hasActions = computed(() => { } return eventData.value.actions?.length > 0 }) -onMounted(() => { - if (!data.value || !data.value.event || !events.value || getMode() !== 'demo') { - return - } - const id = events.value.events.indexOf(data.value.event) - useMeta( - `${eventData.value?.request.method} ${eventData.value?.request.url} – HTTP Request Details`, - 'Inspect full HTTP request and response details including method, URL, headers, parameters, body, status code, response contents, duration, and client IP.', - 'https://mokapi.io/dashboard-demo/http/requests/' + id - ) -}) -onUnmounted(() => { - close() -}) -function isNumber(value: string): boolean { - return /^[0-9]+$/.test(value); -} diff --git a/webui/src/components/dashboard/http/Requests.vue b/webui/src/components/dashboard/http/Requests.vue index 1b0eb6f7a..40acee308 100644 --- a/webui/src/components/dashboard/http/Requests.vue +++ b/webui/src/components/dashboard/http/Requests.vue @@ -7,6 +7,8 @@ import { Modal, Tooltip } from 'bootstrap' import RegexInput from '@/components/RegexInput.vue' import { getRouteName, useDashboard } from '@/composables/dashboard'; import { usePrettyText } from '@/composables/usePrettyText' +import Pager from '../Pager.vue'; +import type { HttpEventFilter } from '@/types/dashboard.js' const props = defineProps({ serviceName: { type: String, required: false}, @@ -14,7 +16,7 @@ const props = defineProps({ method: { type: String, required: false} }) -const labels = ref([{ name: 'namespace', value: 'http' }]) +const labels = ref([{ name: 'namespace', value: 'http' }]) if (props.serviceName){ labels.value.push({ name: 'name', value: props.serviceName }) if (props.path){ @@ -25,9 +27,120 @@ if (props.serviceName){ } } +const paging = reactive({ + current: 1, + size: 25, + sizeOptions: [10, 25, 50, 100] +}) + +const filter = reactive({ + service: { state: 'Not', checkbox: false, value: [] }, + method: { state: 'Not', checkbox: false, value: ['GET']}, + url: { checkbox: false, value: ''}, + request: { + headers: { checkbox: false, value: [{ name: '', value: '' }]} + }, + response: { + headers: { checkbox: false, value: [{ name: '', value: '' }]}, + statusCode: { checkbox: false, value: '', title: `Status Code Filter:
+ • Multiple entries (comma-separated): 200, 404
+ • Negation: prefix with '!' (e.g., !501)
+ • Range: start-end (e.g., 200-300)
+ • Status class: 4xx for all client errors` + } + }, + clientIP: { checkbox: false, value: '', title: `IP Filter Options:
+ • Multiple entries (comma-separated): 192.168.0.1,10.0.0.1
+ • Negation: prefix with '!' (e.g., !127.0.0.1)
+ • CIDR notation: 192.168.0.0/24` } + } +) + +const s = localStorage.getItem(`http-requests-${getFilterCacheKey()}`) +if (s && s !== '') { + const saved = JSON.parse(s) + mergeDeep(filter, saved) +} + +const filters = computed(() => { + const result: HttpEventFilter & { labels?: Label[] } = {} + + if (props.serviceName === undefined) { + if (filter.service.state !== 'Not') { + result.labels = filter.service.value.map(x => ({ name: 'name', value: x})) + } + } + const custom = filter.method.custom?.toUpperCase().split(/[\s,]+/); + switch (filter.method.state) { + case 'Single': + if (filter.method.value[0] === 'CUSTOM') { + result.methods = custom + } else { + result.methods = [filter.method.value[0]] + } + break; + case 'Multi': + const methods = [] + for (const m of filter.method.value) { + if (m === 'CUSTOM' && custom) { + methods.push(...custom) + }else { + methods.push(m) + } + } + result.methods = methods + } + if (filter.url.checkbox && filter.url.value) { + result.url = filter.url.value + } + + if (filter.request.headers.checkbox && filter.request.headers.value?.length > 0) { + result.requestHeaders = filter.request.headers.value.filter(x => x.name !== '' && x.value !== '') + } + + if (filter.response.statusCode.checkbox) { + result.statusCode = filter.response.statusCode.value + } + + if (filter.response.headers.checkbox && filter.response.headers.value?.length > 0) { + result.responseHeaders = filter.response.headers.value.filter(x => x.name !== '' && x.value !== '') + } + + if (filter.clientIP.checkbox && filter.clientIP.value != '') { + result.clientIP = filter.clientIP.value + } + + return result +}) + const router = useRouter() const { dashboard } = useDashboard() -const { events: data, close } = dashboard.value.getEvents(...labels.value) +const result = ref(null); +const events = computed(() => result.value?.items ?? []) +watch( + [() => dashboard.value, () => paging.current, () => paging.size, labels, filters ], + ([db], _, onCleanup) => { + const queryLabels = [...labels.value] + if (filters.value.labels) { + queryLabels.push(...filters.value.labels) + } + const res = db.getEvents({ + page: paging.current, + size: paging.size, + labels: queryLabels, + http: filters.value, + }) + + const stop = watch(res.data, (v) => { result.value = v }, { immediate: true }) + + onCleanup(() => { + stop() + res.close() + }) + }, + { immediate: true } +); + const { format, duration } = usePrettyDates() const { formatStatusCode } = usePrettyHttp() const { adaptiveTruncate } = usePrettyText() @@ -46,6 +159,9 @@ watch( () => res.services.value, (v) => { services.value = v as HttpService[] + if (filter.service.value.length === 0 && services.value.length > 0) { + filter.service.value = [services.value[0].name] + } }, { immediate: true } ) @@ -69,19 +185,19 @@ type CheckboxFilter = 'Not' | 'Single' | 'Multi' interface Filter { service: MultiFilterItem method: MultiFilterItem & { custom?: string} - url: FilterItem + url: FilterItem request: { - headers: FilterItem + headers: FilterItem } response: { - headers: FilterItem - statusCode: FilterItem + headers: FilterItem + statusCode: FilterItem } - clientIP: FilterItem + clientIP: FilterItem } -interface FilterItem { +interface FilterItem { checkbox: boolean - value: any + value: T title?: string } interface MultiFilterItem { @@ -89,27 +205,10 @@ interface MultiFilterItem { state: CheckboxFilter value: string[] } -const filter = reactive({ - service: { state: 'Not', checkbox: false, value: [] }, - method: { state: 'Not', checkbox: false, value: ['GET']}, - url: { checkbox: false, value: null}, - request: { - headers: { checkbox: false, value: [{ name: '', value: '' }]} - }, - response: { - headers: { checkbox: false, value: [{ name: '', value: '' }]}, - statusCode: { checkbox: false, value: '', title: `Status Code Filter:
- • Multiple entries (comma-separated): 200, 404
- • Negation: prefix with '-' (e.g., -501)
- • Range: start-end (e.g., 200-300)` - } - }, - clientIP: { checkbox: false, value: '', title: `IP Filter Options:
- • Multiple entries (comma-separated): 192.168.0.1,10.0.0.1
- • Negation: prefix with '-' (e.g., -127.0.0.1)
- • CIDR notation: 192.168.0.0/24` } - } -) +interface Header { + name: string; + value: string; +} const updateLimit = () => { if (urlCell.value) { @@ -121,11 +220,6 @@ const updateLimit = () => { const observer = new ResizeObserver(updateLimit); onMounted(() => { dialog = Modal.getOrCreateInstance(dialogRef.value!); - const s = localStorage.getItem(`http-requests-${getFilterCacheKey()}`) - if (s && s !== '') { - const saved = JSON.parse(s) - mergeDeep(filter, saved) - } const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]') tooltipTriggerList.forEach(el => new Tooltip(el, { trigger: 'hover' @@ -135,7 +229,7 @@ onMounted(() => { } }) -function goToRequest(event: ServiceEvent, openInNewTab = false){ +function goToRequest(event: ServiceEventInfo, openInNewTab = false){ if (getSelection()?.toString()) { return } @@ -151,161 +245,14 @@ function goToRequest(event: ServiceEvent, openInNewTab = false){ router.push(to) } } -function eventData(event: ServiceEvent): HttpEventData{ - return event.data -} watch(filter, () => { localStorage.setItem(`http-requests-${getFilterCacheKey()}`, JSON.stringify(filter)) }) -const events = computed(() => { - let result = data.value; - if (props.serviceName === undefined) { - switch (filter.service.state) { - case 'Single': - result = result.filter(x => x.traits.name === service.value); - break; - case 'Multi': - result = result.filter(x => x.traits.name && service.value?.includes(x.traits.name)); - } - } - const custom = filter.method.custom?.toUpperCase().split(' '); - switch (filter.method.state) { - case 'Single': - if (filter.method.value[0] === 'CUSTOM') { - result = result.filter(x => custom?.includes((x.data as HttpEventData).request.method)); - } else { - let method = 'GET' - // if in multiple mode all methods are unselected, value contains no method - if (filter.method.value.length > 0) { - method = filter.method.value[0] - } - result = result.filter(x => { - return method === (x.data as HttpEventData).request.method - }); - } - break; - case 'Multi': - result = result.filter(x => { - const method = (x.data as HttpEventData).request.method; - for (const m of filter.method.value) { - if (m === 'CUSTOM') { - if (custom?.includes(method)) { - return true; - } - }else { - if (m === method) { - return true; - } - } - } - return false; - }) - } - if (urlValue.value?.regex) { - result = result.filter(x => { - const data = x.data as HttpEventData - return urlValue.value?.regex.test(data.request.url) - }) - } - - if (filter.request.headers.checkbox && requestHeaderFilter.value?.length > 0) { - result = result.filter(x => { - const data = x.data as HttpEventData - for (const filter of requestHeaderFilter.value) { - if (!filter(data)) { - return false - } - } - return true - }) - } - - if (filter.response.headers.checkbox && responseHeaderFilter.value?.length > 0) { - result = result.filter(x => { - const data = x.data as HttpEventData - for (const filter of responseHeaderFilter.value) { - if (!filter(data)) { - return false - } - } - return true - }) - } - if (filter.clientIP.checkbox && filter.clientIP.value.length > 0) { - const values = (filter.clientIP.value as string).split(',').map(x => x.trim()).filter(x => x.length > 0) - result = result.filter(x => { - const data = x.data as HttpEventData - - let matched = false; - - for (let value of values) { - let not = false - if (value[0] === '-') { - not = true - value = value.substring(1) - } - let isMatch = false - if (value.includes('/')) { - isMatch = cidrMatch(data.clientIP, value) - } else { - isMatch = data.clientIP === value - } - - if (not && isMatch) { - // Negated value matches → reject immediately - return false - } - if (!not && isMatch) { - // Normal value matches → mark as matched - matched = true; - } - } - return matched || values.every(v => v[0] === '-') - }) - } - if (filter.response.statusCode.checkbox && filter.response.statusCode.value.length > 0) { - const values = (filter.response.statusCode.value as string).split(',').map(x => x.trim()).filter(x => x.length > 0) - result = result.filter(x => { - const data = x.data as HttpEventData - - let matched = false; - - for (let value of values) { - let not = false - if (value[0] === '-') { - not = true - value = value.substring(1) - } - let isMatch = false - if (value.includes('-')) { - const parts = value.split('-').map(x => x.trim()) - if (parts.length !== 2) { - continue - } - const min = parseInt(parts[0]!) - const max = parseInt(parts[1]!) - isMatch = data.response.statusCode >= min && data.response.statusCode <= max - } else { - isMatch = data.response.statusCode.toString() === value - } - - if (not && isMatch) { - // Negated value matches → reject immediately - return false - } - if (!not && isMatch) { - // Normal value matches → mark as matched - matched = true; - } - } - return matched || values.every(v => v[0] === '-') - }) - } - - return result -}) +function eventData(event: ServiceEventInfo): HttpEventInfo{ + return event.data +} const hasDeprecatedRequests = computed(() => { if (!events.value) { @@ -373,7 +320,6 @@ const method = computed({ }) onUnmounted(() => { - close() observer.disconnect() }) function showDialog() { @@ -432,7 +378,7 @@ function getFilterCacheKey() { } return `filter-${props.serviceName}-${props.path}-${props.method}` } -function onHeaderInput(headers: FilterItem, index: number) { +function onHeaderInput(headers: FilterItem, index: number) { const last = headers.value[headers.value.length - 1] // If currently editing the last row and it has a name → add new empty row @@ -448,7 +394,7 @@ function onHeaderInput(headers: FilterItem, index: number) { } } } -function removeHeaderFilter(headers: FilterItem, index: number) { +function removeHeaderFilter(headers: FilterItem, index: number) { headers.value.splice(index, 1) // Ensure at least one empty placeholder row @@ -478,114 +424,6 @@ const responseHeaderErrors = computed(() => { } return result }) -const requestHeaderFilter = computed(() => { - const result: ((data: HttpEventData) => boolean)[] = [] - if (!requestHeaderValueRefs.value) { - return result; - } - - for (let i = 0; i < filter.request.headers.value.length - 1; i++) { - if (requestHeaderValueRefs.value[i]?.regexError) { - continue - } - const fh = filter.request.headers.value[i]; - result.push((data: HttpEventData) => { - const params = data.request.parameters - if (!params) { - return false - } - - const name = fh.name?.toLowerCase(); - for (const param of params) { - if (param.type !== 'header') { - continue - } - if (!name) { - const regex = requestHeaderValueRefs.value[i]?.regex - if (regex && regex.test(param.raw)) { - return true - } - } else if (param.name.toLowerCase() === name) { - if (!fh.value) { - return true - } else { - const regex = requestHeaderValueRefs.value[i]?.regex - if (regex && regex.test(param.raw)) { - return true - } - } - } - } - return false - }) - } - return result -}) -const responseHeaderFilter = computed(() => { - const result: ((data: HttpEventData) => boolean)[] = [] - if (!responseHeaderValueRefs.value) { - return result; - } - - for (let i = 0; i < filter.response.headers.value.length - 1; i++) { - if (responseHeaderValueRefs.value[i]?.regexError) { - continue - } - const fh = filter.response.headers.value[i]; - result.push((data: HttpEventData) => { - const headers = data.response.headers - if (!headers) { - return false - } - - const name = fh.name?.toLowerCase(); - for (const fieldName in headers) { - - if (!name) { - const regex = responseHeaderValueRefs.value[i].regex - if (regex && regex.test(headers[name])) { - return true - } - } else if (fieldName.toLowerCase() === name) { - if (!fh.value) { - return true - } else { - const regex = responseHeaderValueRefs.value[i].regex - if (regex && regex.test(headers[fieldName])) { - return true - } - } - } - } - return false - }) - } - return result -}) -function ipToInt(ip: string): number { - return ip - .split('.') - .map((octet) => parseInt(octet, 10)) - .reduce((acc, octet) => (acc << 8) + octet, 0); -} - -function cidrMatch(ip: string, cidr: string): boolean { - const parts: string[] = cidr.split('/'); - if (parts.length !== 2) { - return false - } - - const [range, bitsStr] = parts; - const bits: number = parseInt(bitsStr!, 10); - - if (isNaN(bits) || bits < 0 || bits > 32) { - return false - } - - const mask: number = ~(2 ** (32 - bits) - 1); - - return (ipToInt(ip) & mask) === (ipToInt(range!) & mask); -} function mergeDeep(target: T, source: Partial): T { for (const key in source) { const sourceValue = source[key]; @@ -617,6 +455,7 @@ function mergeDeep(target: T, source: Partial): T {

Recent Requests

+
+
@@ -649,27 +489,55 @@ function mergeDeep(target: T, source: Partial): T { - + - - {{ eventData(event).request.method }} + + {{ event.traits.method }} - {{ adaptiveTruncate(eventData(event).request.url, dynamicLimit) }} + {{ adaptiveTruncate(eventData(event).url, dynamicLimit) }} - {{ formatStatusCode(eventData(event).response.statusCode.toString()) }} + {{ formatStatusCode(eventData(event).statusCode.toString()) }} {{ format(event.time) }} {{ duration(eventData(event).duration) }}
+ + +
+
+ +
+ Showing {{ events.length }} of {{ result.total }} requests +
+ + + + + +
+
+ @@ -759,10 +627,10 @@ function mergeDeep(target: T, source: Partial): T {
- +
- +
+ + +
+
+ +
+ Showing {{ events.length }} of {{ result.total }} requests +
+ + + + + +
+
\ No newline at end of file diff --git a/webui/src/components/dashboard/kafka/Message.vue b/webui/src/components/dashboard/kafka/Message.vue index 83c02210e..940f64d8f 100644 --- a/webui/src/components/dashboard/kafka/Message.vue +++ b/webui/src/components/dashboard/kafka/Message.vue @@ -1,13 +1,12 @@ \ No newline at end of file diff --git a/webui/src/components/dashboard/websocket/WebsocketMessages.vue b/webui/src/components/dashboard/websocket/WebsocketMessages.vue index f08c9b799..bc7716b50 100644 --- a/webui/src/components/dashboard/websocket/WebsocketMessages.vue +++ b/webui/src/components/dashboard/websocket/WebsocketMessages.vue @@ -1,12 +1,8 @@ diff --git a/webui/src/composables/dashboard.backend.ts b/webui/src/composables/dashboard.backend.ts index 13ca65352..dcd627344 100644 --- a/webui/src/composables/dashboard.backend.ts +++ b/webui/src/composables/dashboard.backend.ts @@ -1,6 +1,6 @@ import { reactive, ref, watchEffect } from "vue"; import { transformPath, useFetch } from "./fetch"; -import type { Dashboard, ExampleRequest, ExampleResult, MailboxMessagesResult, MailboxResult } from "@/types/dashboard"; +import type { Dashboard, ExampleRequest, ExampleResult, MailboxMessagesResult, MailboxResult, EventQuery } from "@/types/dashboard"; import { usePrettyLanguage } from "./usePrettyLanguage"; const { formatLanguage } = usePrettyLanguage(); @@ -94,23 +94,60 @@ export const dashboard: Dashboard = { return {client, isLoading, close: response.close} }, - getEvents(...labels: Label[]) { + getEvents(query?: EventQuery) { let url = `/api/events` - for (const [index, label] of labels.entries()) { - let separator = index === 0 ? '?' : '&' - url += `${separator}${label.name}=${label.value}` + + if (query) { + const params = new URLSearchParams() + if (query.labels) { + for (const label of query.labels) { + params.append(`traits[${label.name}]`, label.value) + } + } + if (query.http) { + if (query.http.methods) { + params.append('http[method]', query.http.methods.join(',')) + } + if (query.http.url) { + params.append('http[url]', query.http.url) + } + for (const h of query.http.requestHeaders ?? []) { + params.append('http[requestHeader]', `${h.name}${h.value ? ':'+h.value : ''}`) + } + if (query.http.statusCode) { + params.append('http[statusCode]', query.http.statusCode) + } + for (const h of query.http.responseHeaders ?? []) { + params.append('http[responseHeader]', `${h.name}${h.value ? ':'+h.value : ''}`) + } + if (query.http.clientIP) { + params.append('http[clientIP]', query.http.clientIP) + } + } + if (query.page !== undefined) { + params.append('page', query.page.toString()) + } + if (query.size !== undefined) { + params.append('size', query.size.toString()) + } + + url += `?${params.toString()}` } + + + const res = useFetch(url) - const events = ref([]) + const data = ref({ items: [], page: 1, size: 25, total: 0 }) watchEffect(() => { if (!res.data){ - events.value = [] + data.value.items = [] + data.value.total = 0 return } - events.value = res.data + data.value = res.data }) - return { events, close: res.close } + return { data, close: res.close } }, getEvent(id: string){ diff --git a/webui/src/composables/dashboard.demo.ts b/webui/src/composables/dashboard.demo.ts index 1587ce7e6..4a72cf594 100644 --- a/webui/src/composables/dashboard.demo.ts +++ b/webui/src/composables/dashboard.demo.ts @@ -1,7 +1,8 @@ import { reactive, ref, watchEffect } from "vue"; import { transformPath, useFetch } from "./fetch"; -import type { AppInfoResponse, BrunoCollection, Dashboard, ExampleRequest, ExampleResult, MailboxMessagesResult, MailboxResult } from "@/types/dashboard"; +import type { AppInfoResponse, EventQuery, Dashboard, ExampleRequest, ExampleResult, MailboxMessagesResult, MailboxResult } from "@/types/dashboard"; import type { Response } from "./fetch"; +import { match, matchTraits } from '../../lib/events' export function useDemoDashboard() { @@ -132,10 +133,14 @@ export function useDemoDashboard() { return { client, isLoading, close: () => {} } }, - getEvents(...labels: Label[]) { - const events = ref([]) + getEvents(query?: EventQuery) { + const page = query?.page ?? 1 + const size = query?.size ?? 25 + const data = ref({ items: [], page, size, total: 0 }) const isLoading = ref(true) + console.log(query) + watchEffect(() => { if (!db.data) { return @@ -143,21 +148,23 @@ export function useDemoDashboard() { const result = [] for (const event of db.data['events']) { - let isValid = true - for (const label of labels) { - if (event.traits[label.name] !== label.value) { - isValid = false - } + if (query?.labels && !matchTraits(event, query?.labels)) { + continue + } + const detail = db.data['event_' + event.id] + if (!detail) { + continue } - if (isValid) { + if (match(detail, query)) { result.push(event) } } - events.value = result + data.value.items = result.slice((page - 1) * size, page * size) + data.value.total = result.length isLoading.value = db.isLoading }) - return { events, isLoading, close: () => {} } + return { data, isLoading, close: () => {} } }, getEvent(id: string) { @@ -168,12 +175,7 @@ export function useDemoDashboard() { if (!db.data) { return } - const events = db.data['events']; - for (const e of events) { - if (e.id === id) { - event.value = e; - } - } + event.value = db.data['event_'+id]; isLoading.value = db.isLoading }) return { event, isLoading, close: () => {} } diff --git a/webui/src/composables/ldap.ts b/webui/src/composables/ldap.ts deleted file mode 100644 index 9043da73b..000000000 --- a/webui/src/composables/ldap.ts +++ /dev/null @@ -1,38 +0,0 @@ -export function useLdap() { - - function targetDn (data: LdapEventData) { - switch (data.request.operation) { - case 'Bind': - return data.request.name; - case 'Search': - return data.request.baseDN - case 'Modify': - case 'Add': - case 'Delete': - case 'ModifyDN': - case 'Compare': - return data.request.dn - } - } - - function criteria(data: LdapEventData) { - switch (data.request.operation) { - case 'Search': - return data.request.filter - case 'Compare': - return `${data.request.attribute} == ${data.request.value}` - case 'Modify': - const result = [] - for (const item of data.request.items) { - result.push(`${item.modification.toLocaleLowerCase()} ${item.attribute.type}`) - } - return result.join('
') - case 'ModifyDN': - return data.request.newRdn - default: - undefined; - } - } - - return { targetDn, criteria } -} \ No newline at end of file diff --git a/webui/src/composables/meta.ts b/webui/src/composables/meta.ts index 04a42aa5d..049e44a7b 100644 --- a/webui/src/composables/meta.ts +++ b/webui/src/composables/meta.ts @@ -51,8 +51,7 @@ function setCanonical(href: string) { canonical.rel = 'canonical' document.getElementsByTagName('head')[0]!.appendChild(canonical); } - canonical.href = href; - + canonical.href = href; } export function useSoftwareApplicationMeta(version: string) { diff --git a/webui/src/router/index.ts b/webui/src/router/index.ts index 21b261396..e06bfffd0 100644 --- a/webui/src/router/index.ts +++ b/webui/src/router/index.ts @@ -1,4 +1,3 @@ -import Events from '@/components/dashboard/Events.vue' import Search from '@/components/dashboard/Search.vue' import { getRouteName } from '@/composables/dashboard' import { createRouter, createWebHistory, useRoute as baseRoute, type RouteLocationRaw, type RouteRecordRaw, type MatcherLocation } from 'vue-router' @@ -65,12 +64,6 @@ function createDashboardRoute(mode: 'live' | 'demo'): RouteRecordRaw { path: 'services', name: getRouteName('serviceList'), component: dashboardView - }, - { - path: 'events', - name: getRouteName('events'), - component: Events, - meta: { title: 'Dashboard - Events'} }, { path: 'search', @@ -94,7 +87,11 @@ function createDashboardRoute(mode: 'live' | 'demo'): RouteRecordRaw { path: 'requests/:id', name: getRouteName('httpRequest'), component: dashboardView, - meta: { service: 'http', title: ({ params }: MatcherLocation) => `Dashboard - HTTP Request – ${params.id}` } + meta: { + service: 'http', + title: ({ params }: MatcherLocation) => `Dashboard - HTTP Request – ${params.id}`, + noindex: true + } }, { path: 'services/:service/:endpoint(.*)*', @@ -147,13 +144,21 @@ function createDashboardRoute(mode: 'live' | 'demo'): RouteRecordRaw { path: 'messages/:id', name: getRouteName('kafkaMessage'), component: dashboardView, - meta: { service: 'kafka', title: ({ params }: MatcherLocation) => `Dashboard - Kafka Message – ${params.id}` } + meta: { + service: 'kafka', + title: ({ params }: MatcherLocation) => `Dashboard - Kafka Message – ${params.id}`, + noindex: true + } }, { path: 'service/:service/requests/:id', name: getRouteName('kafkaRequest'), component: dashboardView, - meta: { service: 'kafka', title: ({ params }: MatcherLocation) => `Dashboard - Kafka Request – ${params.id}` } + meta: { + service: 'kafka', + title: ({ params }: MatcherLocation) => `Dashboard - Kafka Request – ${params.id}`, + noindex: true + } }, { path: 'service/:service/clients/:clientId', @@ -179,7 +184,11 @@ function createDashboardRoute(mode: 'live' | 'demo'): RouteRecordRaw { path: 'ldap/requests/:id', name: getRouteName('ldapRequest'), component: dashboardView, - meta: { service: 'ldap', title: ({ params }: MatcherLocation) => `Dashboard - LDAP Request – ${params.id}` } + meta: { + service: 'ldap', + title: ({ params }: MatcherLocation) => `Dashboard - LDAP Request – ${params.id}`, + noindex: true + } }, ] }, @@ -205,7 +214,11 @@ function createDashboardRoute(mode: 'live' | 'demo'): RouteRecordRaw { path: 'mail/mails/:id', name: getRouteName('smtpMail'), component: dashboardView, - meta: { service: 'mail', title: ({ params }: MatcherLocation) => `Dashboard - Mail – ${params.id}` } + meta: { + service: 'mail', + title: ({ params }: MatcherLocation) => `Dashboard - Mail – ${params.id}`, + noindex: true + } }, ] }, @@ -240,13 +253,21 @@ function createDashboardRoute(mode: 'live' | 'demo'): RouteRecordRaw { path: 'messages/:id', name: getRouteName('mqttMessage'), component: dashboardView, - meta: { service: 'mqtt', title: ({ params }: MatcherLocation) => `Dashboard - MQTT Message – ${params.id}` } + meta: { + service: 'mqtt', + title: ({ params }: MatcherLocation) => `Dashboard - MQTT Message – ${params.id}`, + noindex: true + } }, { path: 'service/:service/requests/:id', name: getRouteName('mqttRequest'), component: dashboardView, - meta: { service: 'mqtt', title: ({ params }: MatcherLocation) => `Dashboard - MQTT Request – ${params.id}` } + meta: { + service: 'mqtt', + title: ({ params }: MatcherLocation) => `Dashboard - MQTT Request – ${params.id}`, + noindex: true + } }, { path: 'service/:service/clients/:clientId', @@ -287,7 +308,11 @@ function createDashboardRoute(mode: 'live' | 'demo'): RouteRecordRaw { path: 'messages/:id', name: getRouteName('websocketMessage'), component: dashboardView, - meta: { service: 'websocket', title: ({ params }: MatcherLocation) => `Dashboard - Websocket Message – ${params.id}` } + meta: { + service: 'websocket', + title: ({ params }: MatcherLocation) => `Dashboard - Websocket Message – ${params.id}`, + noindex: true + } }, { path: 'service/:service/clients/:id', @@ -299,7 +324,11 @@ function createDashboardRoute(mode: 'live' | 'demo'): RouteRecordRaw { path: 'service/:service/events/:id', name: getRouteName('websocketEvent'), component: dashboardView, - meta: { service: 'websocket', title: ({ params }: MatcherLocation) => `Dashboard - Websocket Event – ${params.id}` } + meta: { + service: 'websocket', + title: ({ params }: MatcherLocation) => `Dashboard - Websocket Event – ${params.id}`, + noindex: true + } }, ] }, @@ -319,7 +348,10 @@ function createDashboardRoute(mode: 'live' | 'demo'): RouteRecordRaw { path: 'config/:id', name: getRouteName('config'), component: dashboardView, - meta: { title: ({ params }: MatcherLocation) => `Dashboard - Config – ${params.id}` } + meta: { + title: ({ params }: MatcherLocation) => `Dashboard - Config – ${params.id}`, + noindex: true + } }, { path: 'tree', @@ -472,26 +504,24 @@ const router = createRouter({ }) // add refresh query parameter if clicked link does not have it but current route has it -router.beforeEach((to, from, next) => { +router.beforeEach((to, from) => { if (!to.path.startsWith('/dashboard') || to.path.startsWith('/dashboard-demo')) { - return next() + return } const hadRefresh = !!from.query.refresh; const hasRefresh = !!to.query.refresh; if (hadRefresh && !hasRefresh) { - return next({ + return{ path: to.path, query: { ...to.query, refresh: from.query.refresh }, hash: to.hash, - }) + } } - - return next() }); if (import.meta.env.VITE_DASHBOARD === 'true') { @@ -528,4 +558,19 @@ function waitForElement(selector: string, timeout = 2000): Promise { check() }) -} \ No newline at end of file +} + +router.afterEach((to) => { + let tag = document.querySelector('meta[name="robots"]') + + if (to.meta.noindex) { + if (!tag) { + tag = document.createElement('meta') + tag.name = 'robots' + document.head.appendChild(tag) + } + tag.content = 'noindex' + } else { + tag?.remove() + } +}) \ No newline at end of file diff --git a/webui/src/types/dashboard.d.ts b/webui/src/types/dashboard.d.ts index 188acfe82..a4cb17ada 100644 --- a/webui/src/types/dashboard.d.ts +++ b/webui/src/types/dashboard.d.ts @@ -1,4 +1,3 @@ -import type { AppInfoResponse } from "@/composables/appInfo" import type { Response } from "@/composables/fetch" import type { Ref } from "vue" @@ -13,7 +12,7 @@ export interface Dashboard { getKafkaGroup(serviceName: string, groupName: string): KafkaGroupResult getKafkaClient(serviceName: string, clientId: string): KafkaClientResult - getEvents(...labels: Label[]): EventsResult + getEvents(query?: EventQuery): EventsResult getEvent(id: string): EventResult getExample(request: ExampleRequest): ExampleResult @@ -74,7 +73,7 @@ export interface KafkaTopicResult { } export interface EventsResult { - events: Ref + data: Ref close(): void } @@ -140,4 +139,25 @@ export interface ConfigDataResult { export interface BrunoCollection { serviceName: string content: string +} + +export interface EventQuery { + labels: Label[] + http?: HttpEventFilter + + page: number + size: number +} + +export interface HttpEventFilter { + methods?: string[] + url?: string + requestHeaders?: { name: string, value: string }[] + statusCode?: string + responseHeaders?: { name: string, value: string }[] + clientIP?: string +} + +export interface KafkaEventFilter { + } \ No newline at end of file diff --git a/webui/src/types/http.d.ts b/webui/src/types/http.d.ts index e2f177456..7e9986a2b 100644 --- a/webui/src/types/http.d.ts +++ b/webui/src/types/http.d.ts @@ -57,6 +57,13 @@ declare interface HttpParameter { schema?: Schema } +declare interface HttpEventInfo { + url: string + statusCode: number + duration: number + deprecated: boolean +} + declare interface HttpEventData { request: HttpEventRequest response: HttpEventResponse diff --git a/webui/src/types/kafka.d.ts b/webui/src/types/kafka.d.ts index 42ff0de72..6f2fbcafc 100644 --- a/webui/src/types/kafka.d.ts +++ b/webui/src/types/kafka.d.ts @@ -102,6 +102,14 @@ declare interface KafkaMember { partitions: { [topicName: string]: number[] }; } +declare type KafkaEventInfo = KafkaMessageInfo | KafkaRequestLog + +declare interface KafkaMessageInfo { + key: KafkaValue; + message: KafkaValue; + deleted: boolean +} + declare type KafkaEventData = KafkaMessageData | KafkaRequestLog declare interface KafkaMessageData { diff --git a/webui/src/types/ldap.d.ts b/webui/src/types/ldap.d.ts index 82086e769..9ce522f97 100644 --- a/webui/src/types/ldap.d.ts +++ b/webui/src/types/ldap.d.ts @@ -2,6 +2,14 @@ declare interface LdapService extends Service { server: string } +declare interface LdapEventInfo { + operation: string + dn: string + criteria: string + status: string + duration: number +} + declare interface LdapEventData { request: LdapBindRequest | LdapUnbindRequest | LdapSearchRequest | LdapModifyRequest | LdapAddRequest | LdapDeleteRequest | LdapModifDNRequest | LdapCompareRequest response: LdapSearchResponse | LdapResponse diff --git a/webui/src/types/mail.d.ts b/webui/src/types/mail.d.ts index 3b4dcb1e6..9f62bf425 100644 --- a/webui/src/types/mail.d.ts +++ b/webui/src/types/mail.d.ts @@ -43,6 +43,14 @@ declare interface RejectResponse { message: string } +declare interface SmtpEventInfo { + subject: string + from: string + to: string[] + duration: number + messageId: string +} + declare interface SmtpEventData { from: string to: string[] diff --git a/webui/src/types/mqtt.d.ts b/webui/src/types/mqtt.d.ts index d79465200..71fe068b3 100644 --- a/webui/src/types/mqtt.d.ts +++ b/webui/src/types/mqtt.d.ts @@ -57,6 +57,13 @@ declare interface MqttBroker { declare type MqttEventData = MqttMessageData | MqttRequestLog +declare type MqttEventInfo = MqttMessageInfo | MqttRequestLog + +declare interface MqttMessageInfo { + topic: string + message: MqttMessage +} + declare interface MqttMessageData { topic: string message: MqttMessage; diff --git a/webui/src/types/service.d.ts b/webui/src/types/service.d.ts index eb5a9e570..94a66f189 100644 --- a/webui/src/types/service.d.ts +++ b/webui/src/types/service.d.ts @@ -25,6 +25,20 @@ declare interface Label { value: string } +declare interface ServiceEventResult { + items: ServiceEventInfo[] + page: number + size: number + total: number +} + +declare interface ServiceEventInfo { + id: string + data: HttpEventInfo | KafkaEventInfo | MqttEventInfo | WebsocketEventData | SmtpEventInfo | LdapEventInfo | JobExecution | LogMessage + time: string + traits: Traits +} + declare interface ServiceEvent { id: string data: HttpEventData | KafkaEventData | MqttEventData | WebsocketEventData | SmtpEventData | LdapEventData | JobExecution | LogMessage diff --git a/webui/src/views/DashboardView.vue b/webui/src/views/DashboardView.vue index e6f49c47d..4c4588f82 100644 --- a/webui/src/views/DashboardView.vue +++ b/webui/src/views/DashboardView.vue @@ -3,7 +3,6 @@ import AppStartCard from '../components/dashboard/AppStartCard.vue' import MemoryUsageCard from '../components/dashboard/MemoryCard.vue' import JobCountCard from '@/components/dashboard/JobCountCard.vue' import Search from '../components/dashboard/Search.vue' -import Events from '../components/dashboard/Events.vue' import HttpRequestCard from '../components/dashboard/http/HttpRequestMetricCard.vue' import HttpServicesCard from '../components/dashboard/http/HttpServicesCard.vue' @@ -272,10 +271,6 @@ router.afterEach((to) => {
-
- -
- From 3201171cd8789ea0b6c734439ab8b9fa8049499c Mon Sep 17 00:00:00 2001 From: marle3003 Date: Tue, 13 Oct 2026 23:03:15 +0200 Subject: [PATCH 06/49] fix: update mount path to include lib folder --- .github/actions/run-frontend-tests/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/run-frontend-tests/action.yml b/.github/actions/run-frontend-tests/action.yml index a5354cb88..eab194710 100644 --- a/.github/actions/run-frontend-tests/action.yml +++ b/.github/actions/run-frontend-tests/action.yml @@ -23,7 +23,7 @@ runs: run: docker load --input /tmp/mokapi.tar shell: bash - name: Run mokapi image - run: docker run --name mokapi --rm -d -p 8090:8090 -p 8091:8091 --mount type=bind,source=$(pwd)/webui/e2e/mocks,target=/data --env MOKAPI_Log_Level=Debug --env MOKAPI_Api_Port=8091 --env MOKAPI_Api_Path=/mokapi --env MOKAPI_Providers_File_Directory=/data ${{ inputs.image-name }} + run: docker run --name mokapi --rm -d -p 8090:8090 -p 8091:8091 --mount type=bind,source=$(pwd)/webui,target=/data --env MOKAPI_Log_Level=Debug --env MOKAPI_Api_Port=8091 --env MOKAPI_Api_Path=/mokapi --env MOKAPI_Providers_File_Directory=/data/e2e/mocks ${{ inputs.image-name }} shell: bash - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: From 06b3300f6648cb9b184f304f2ad17dbb88e3c344 Mon Sep 17 00:00:00 2001 From: marle3003 Date: Wed, 14 Oct 2026 14:57:17 +0200 Subject: [PATCH 07/49] feat(webui): improve event paging feat(webui): improve payload in the event endpoint by truncating body for the action overview --- engine/common/http.go | 25 +++++++ engine/host.go | 7 ++ js/mokapi/on.go | 31 +++----- lib/jsonpreview/truncated.go | 40 ++++++++++ webui/e2e/mocks/http.ts | 73 +++++++++++++++++++ webui/e2e/tests/dashboard/http/filter.spec.ts | 14 ++-- webui/src/components/dashboard/Actions.vue | 35 ++++++++- webui/src/components/dashboard/Pager.vue | 3 +- webui/src/components/dashboard/Paging.vue | 35 +++++++++ webui/src/components/dashboard/Search.vue | 9 +-- .../components/dashboard/http/Requests.vue | 38 +++------- .../dashboard/kafka/KafkaMessages.vue | 36 +++------ .../dashboard/kafka/KafkaRequests.vue | 30 +++----- .../components/dashboard/ldap/Requests.vue | 38 +++------- webui/src/components/dashboard/mail/Mails.vue | 36 +++------ .../dashboard/mqtt/MqttMessages.vue | 27 ++----- .../dashboard/mqtt/MqttRequests.vue | 30 +++----- .../dashboard/websocket/WebsocketEvents.vue | 29 +++----- .../dashboard/websocket/WebsocketMessages.vue | 29 +++----- webui/src/composables/paging.ts | 16 ++++ webui/src/types/http.d.ts | 6 ++ 21 files changed, 338 insertions(+), 249 deletions(-) create mode 100644 lib/jsonpreview/truncated.go create mode 100644 webui/src/components/dashboard/Paging.vue create mode 100644 webui/src/composables/paging.ts diff --git a/engine/common/http.go b/engine/common/http.go index 9e48163bb..170c322b9 100644 --- a/engine/common/http.go +++ b/engine/common/http.go @@ -2,6 +2,7 @@ package common import ( "fmt" + "mokapi/lib/jsonpreview" "reflect" "strings" ) @@ -53,6 +54,17 @@ func (r *HttpEventRequest) String() string { return s } +func (r *HttpEventRequest) Snapshot() any { + type alias HttpEventRequest + return struct { + *alias + Body jsonpreview.Truncated `json:"body"` + }{ + alias: (*alias)(r), + Body: jsonpreview.Truncated{V: r.Body, Limit: 256 << 10}, + } +} + func (u Url) String() string { sb := strings.Builder{} sb.WriteString(u.Scheme) @@ -71,6 +83,19 @@ func (r *HttpEventResponse) HasBody() bool { return len(r.Body) > 0 || r.Data != nil } +func (r *HttpEventResponse) Snapshot() any { + type alias HttpEventResponse + return struct { + *alias + Body jsonpreview.Truncated `json:"body"` + Data jsonpreview.Truncated `json:"data"` + }{ + alias: (*alias)(r), + Body: jsonpreview.Truncated{V: r.Body, Limit: 256 << 10}, + Data: jsonpreview.Truncated{V: r.Body, Limit: 256 << 10}, + } +} + func HttpEventHandler(req *HttpEventRequest, res *HttpEventResponse, resources any) (bool, error) { resource := getResource(req.Url, resources) if resource == nil { diff --git a/engine/host.go b/engine/host.go index 2e071ab88..8b48a2810 100644 --- a/engine/host.go +++ b/engine/host.go @@ -20,6 +20,10 @@ import ( log "github.com/sirupsen/logrus" ) +type Snapshotter interface { + Snapshot() any +} + type eventHandler struct { handler common.EventHandler tags map[string]string @@ -382,6 +386,9 @@ func (sh *scriptHost) endEventHandler() { func getDeepCopy(args []any) []string { result := make([]string, len(args)) for i, arg := range args { + if s, ok := arg.(Snapshotter); ok { + arg = s.Snapshot() + } b, _ := json.Marshal(arg) result[i] = string(b) } diff --git a/js/mokapi/on.go b/js/mokapi/on.go index 4c39e98aa..14a12d548 100644 --- a/js/mokapi/on.go +++ b/js/mokapi/on.go @@ -2,6 +2,7 @@ package mokapi import ( "bytes" + "crypto/sha256" "encoding/json" "fmt" "mokapi/engine/common" @@ -49,7 +50,7 @@ func (m *Module) On(event string, do goja.Value, vArgs goja.Value) { func getHandler(do goja.Value, args onArgs, vm *goja.Runtime, loop *eventloop.EventLoop) common.EventHandler { return func(ctx *common.EventContext) (bool, error) { - origin, err := getHashes(ctx.Args...) + origin, err := hashOf(ctx.Args) if err != nil { return false, err } @@ -81,7 +82,7 @@ func getHandler(do goja.Value, args onArgs, vm *goja.Runtime, loop *eventloop.Ev return args.track(params...) } - newHashes, err := getHashes(ctx.Args...) + newHashes, err := hashOf(ctx.Args) if err != nil { return false, err } @@ -153,26 +154,18 @@ func getOnArgs(vm *goja.Runtime, args goja.Value) (onArgs, error) { return onArgs{}, nil } -func getHashes(args ...any) ([][]byte, error) { - var result [][]byte - for _, arg := range args { - b, err := json.Marshal(arg) - if err != nil { - return nil, fmt.Errorf("failed to marshal arg: %v", err) - } - result = append(result, b) +func hashOf(v any) ([32]byte, error) { + h := sha256.New() + var sum [32]byte + if err := json.NewEncoder(h).Encode(v); err != nil { + return sum, fmt.Errorf("failed to marshal arg: %v", err) } - return result, nil + copy(sum[:], h.Sum(nil)) + return sum, nil } -func haveChanges(origin [][]byte, new [][]byte) bool { - for i, o := range origin { - n := new[i] - if !bytes.Equal(o, n) { - return true - } - } - return false +func haveChanges(origin, new [32]byte) bool { + return !bytes.Equal(origin[:], new[:]) } func ArgToJs(arg any, vm *goja.Runtime) goja.Value { diff --git a/lib/jsonpreview/truncated.go b/lib/jsonpreview/truncated.go new file mode 100644 index 000000000..c5de6bf63 --- /dev/null +++ b/lib/jsonpreview/truncated.go @@ -0,0 +1,40 @@ +package jsonpreview + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "unicode/utf8" +) + +type Truncated struct { + V any + Limit int +} + +func (t Truncated) MarshalJSON() ([]byte, error) { + data, err := json.Marshal(t.V) + if err != nil { + return nil, err + } + if len(data) <= t.Limit { + return data, nil + } + cut := t.Limit + // do not cut in the middle of a UTF-8 char + for cut > 0 && !utf8.RuneStart(data[cut]) { + cut-- + } + sum := sha256.Sum256(data) + return json.Marshal(struct { + Truncated bool `json:"truncated"` + Size int `json:"size"` + Hash string `json:"hash"` + Preview string `json:"preview"` + }{ + Truncated: true, + Size: len(data), + Hash: hex.EncodeToString(sum[:]), + Preview: string(data[:cut]), + }) +} diff --git a/webui/e2e/mocks/http.ts b/webui/e2e/mocks/http.ts index d727bc9f9..54f93a099 100644 --- a/webui/e2e/mocks/http.ts +++ b/webui/e2e/mocks/http.ts @@ -971,5 +971,78 @@ export let events = [ duration: 133, clientIP: '192.0.1.127' } + }, + { + id: "6f954c79-c8ff-45bb-bddf-c88da57d53d9", + traits: { + namespace: "http", + name: "Swagger Petstore", + path: "/requestWithParams", + method: 'GET' + }, + time: '2023-02-13T10:05:25.583366+01:00', + data: { + request: { + method: "GET", + url: "http://127.0.0.1:18080/requestWithParams", + contentType: "application/json", + parameters: [], + body: '"Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor"' + }, + response: { + statusCode: 200, + headers: { + "Content-Type": "application/json", + }, + body: `"Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor"`, + size: 512 + }, + duration: 133, + actions: [ + { + duration: 20, + tags: { + name: "dashboard", + file: "examples/mokapi/http_handler.js", + fileKey: "b6fea8ac-56c7-4e73-a9c0-6887640bdca8", + event: "http" + }, + logs: [], + parameters: [ + JSON.stringify({ + method: "POST", + url: "http://127.0.0.1:18080/pet", + parameters: [ + { + name: 'Accept-Encoding', + type: 'header', + raw: 'gzip, deflate' + } + ], + contentType: "application/xml", + body: { + truncated: true, + size: 1000, + hash: 'abc', + preview: 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor' + } + }), + JSON.stringify({ + statusCode: 200, + headers: { + "Content-Type": "application/json" + }, + data: { + truncated: true, + size: 1000, + hash: 'abc', + preview: 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor' + } + }) + ] + } + ], + clientIP: '192.0.1.127' + } } ] \ No newline at end of file diff --git a/webui/e2e/tests/dashboard/http/filter.spec.ts b/webui/e2e/tests/dashboard/http/filter.spec.ts index 830d5a3e2..baf5aa006 100644 --- a/webui/e2e/tests/dashboard/http/filter.spec.ts +++ b/webui/e2e/tests/dashboard/http/filter.spec.ts @@ -11,7 +11,7 @@ test.describe('Visit HTTP dashboard', () => { const table = page.getByRole('table', { name: 'Recent Requests' }); let rows = table.locator('tbody tr'); - await expect(rows).toHaveCount(5) + await expect(rows).toHaveCount(6) const filterButton = page.getByRole('button', { name: 'Filter' }) const dialog = page.getByRole('dialog', { name: 'Filter' }) @@ -36,7 +36,7 @@ test.describe('Visit HTTP dashboard', () => { await expect(rows).toHaveCount(0) await names.selectOption('Swagger Petstore') - await expect(rows).toHaveCount(5) + await expect(rows).toHaveCount(6) await dialog.getByRole('button', { name: 'Close' }).click() }) @@ -102,13 +102,13 @@ test.describe('Visit HTTP dashboard', () => { await dialog.getByRole('checkbox', { name: 'Status Code' }).check() await dialog.getByRole('textbox', { name: 'Status Code' }).fill('200') - await expect(rows).toHaveCount(1) + await expect(rows).toHaveCount(2) await dialog.getByRole('textbox', { name: 'Status Code' }).fill('200-400') - await expect(rows).toHaveCount(3) + await expect(rows).toHaveCount(4) await dialog.getByRole('textbox', { name: 'Status Code' }).fill('2xx') - await expect(rows).toHaveCount(3) + await expect(rows).toHaveCount(4) await dialog.getByRole('textbox', { name: 'Status Code' }).fill('!2xx') await expect(rows).toHaveCount(2) @@ -126,7 +126,7 @@ test.describe('Visit HTTP dashboard', () => { await dialog.getByRole('textbox', { name: 'Response Header Name 1' }).fill('Content-Type') await dialog.getByRole('textbox', { name: 'Response Header Value 1' }).fill('application/json') - await expect(rows).toHaveCount(5) + await expect(rows).toHaveCount(6) await resetFilters() await filterButton.click() @@ -153,7 +153,7 @@ test.describe('Visit HTTP dashboard', () => { await expect(rows).toHaveCount(4) await dialog.getByRole('textbox', { name: 'Client IP' }).fill('192.0.1.1/24') - await expect(rows).toHaveCount(1) + await expect(rows).toHaveCount(2) await dialog.getByRole('button', { name: 'Close' }).click() await expect(filterButton).toContainText('1') diff --git a/webui/src/components/dashboard/Actions.vue b/webui/src/components/dashboard/Actions.vue index 5f23ceb48..81659d54c 100644 --- a/webui/src/components/dashboard/Actions.vue +++ b/webui/src/components/dashboard/Actions.vue @@ -5,6 +5,7 @@ import SourceView from './SourceView.vue' import { usePrettyLanguage } from '@/composables/usePrettyLanguage' import { usePrettyText } from '@/composables/usePrettyText' import { getRouteName } from '@/composables/dashboard' +import { usePrettyBytes } from '@/composables/usePrettyBytes.ts' defineProps({ actions: { type: Object as PropType, required: true }, @@ -14,6 +15,7 @@ const { formatLanguage } = usePrettyLanguage() const { duration } = usePrettyDates() const { parseUrls } = usePrettyText() +const { format: formatBytes } = usePrettyBytes() let status: string | null function getName(action: Action){ @@ -87,6 +89,37 @@ function getStatus(action: Action) { } return 'success' } + +const isTruncated = function(v: any): v is Truncated { + return v !== null && typeof v === 'object' && v.truncated === true +} + +function formatParam(raw: any) { + const truncated: Truncated[] = [] + if (!raw) { + return { text: undefined, truncated } + } + + const token = `__TRUNCATED_${crypto.randomUUID()}_` + const obj = JSON.parse(raw, (_key, value) => { + if (isTruncated(value)) { + truncated.push(value) + return `${token}${truncated.length - 1}__` + } + return value + }) + + const text = JSON.stringify(obj, null, 2).replace( + new RegExp(`"${token}(\\d+)__"`, 'g'), + (_m, i) => { + const t = truncated[Number(i)] + return `"${t.preview}…" ⟨${formatBytes(t.size - t.preview.length)} more, truncated⟩` + } + ) + + return { text, truncated } +} +