From 4fd7ba4dcf1f9d9d203d74bebae84817fa61a391 Mon Sep 17 00:00:00 2001 From: Christian Giese Date: Fri, 11 Sep 2026 18:41:39 +0000 Subject: [PATCH 1/9] WEBUI Initial Draft (WIP) --- cmd/bngblasterctrl/bngblasterctrld.go | 8 +- docs/embed.go | 14 + docs/swagger.yaml | 548 +++++ logo.png | Bin 0 -> 76793 bytes pkg/controller/model.go | 89 +- pkg/controller/process.go | 11 +- pkg/controller/repository.go | 90 +- pkg/controller/repository_test.go | 50 +- pkg/controller/repositorymock.go | 59 +- pkg/controller/td/exists/run.stderr | 1 + pkg/server/apidocs.go | 31 + pkg/server/cache.go | 108 + pkg/server/files.go | 62 + pkg/server/files_test.go | 138 ++ pkg/server/logs.go | 207 ++ pkg/server/logs_test.go | 99 + pkg/server/options.go | 62 + pkg/server/overview.go | 94 + pkg/server/server.go | 132 +- pkg/server/server_test.go | 5 +- pkg/server/sessions.go | 170 ++ pkg/server/streams.go | 254 +++ pkg/server/summary_test.go | 389 ++++ pkg/server/ui.go | 129 ++ pkg/server/ui_test.go | 88 + pkg/server/webui/index.html | 517 +++++ pkg/server/webui/static/css/app.css | 821 +++++++ pkg/server/webui/static/img/logo.png | Bin 0 -> 76793 bytes pkg/server/webui/static/js/app.js | 2940 +++++++++++++++++++++++++ 29 files changed, 7077 insertions(+), 39 deletions(-) create mode 100644 docs/embed.go create mode 100644 logo.png create mode 100644 pkg/server/apidocs.go create mode 100644 pkg/server/cache.go create mode 100644 pkg/server/files.go create mode 100644 pkg/server/files_test.go create mode 100644 pkg/server/logs.go create mode 100644 pkg/server/logs_test.go create mode 100644 pkg/server/options.go create mode 100644 pkg/server/overview.go create mode 100644 pkg/server/sessions.go create mode 100644 pkg/server/streams.go create mode 100644 pkg/server/summary_test.go create mode 100644 pkg/server/ui.go create mode 100644 pkg/server/ui_test.go create mode 100644 pkg/server/webui/index.html create mode 100644 pkg/server/webui/static/css/app.css create mode 100644 pkg/server/webui/static/img/logo.png create mode 100644 pkg/server/webui/static/js/app.js diff --git a/cmd/bngblasterctrl/bngblasterctrld.go b/cmd/bngblasterctrl/bngblasterctrld.go index 34d29c3..b8f5292 100644 --- a/cmd/bngblasterctrl/bngblasterctrld.go +++ b/cmd/bngblasterctrl/bngblasterctrld.go @@ -22,6 +22,9 @@ func main() { directory := flag.String("d", controller.DefaultConfigFolder, "config folder") executable := flag.String("e", controller.DefaultExecutable, "bngblaster executable") upload := flag.Bool("upload", false, "allow file upload") + ui := flag.Bool("ui", true, "serve the embedded web UI on /") + interfacesAPI := flag.Bool("interfaces-api", true, "expose the /api/v1/interfaces endpoint") + schema := flag.String("schema", server.DefaultSchemaPath, "path to the bngblaster configuration JSON schema served on /api/v1/schema") // logging debug := flag.Bool("debug", false, "turn on debug logging") @@ -37,7 +40,10 @@ func main() { controller.WithConfigFolder(*directory), controller.WithExecutable(*executable), controller.WithUpload(*upload)) - srv := server.NewServer(repo) + srv := server.NewServer(repo, + server.WithUI(*ui), + server.WithInterfacesAPI(*interfacesAPI), + server.WithSchemaPath(*schema)) srv.Version = Version serve(*addr, srv) } diff --git a/docs/embed.go b/docs/embed.go new file mode 100644 index 0000000..145146e --- /dev/null +++ b/docs/embed.go @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (C) 2020-2025, RtBrick, Inc. + +// Package docs embeds this directory's OpenAPI/Swagger definition and its +// Swagger UI viewer page - the same files GitHub Pages serves at +// https://rtbrick.github.io/bngblaster-controller - so a running controller +// can also serve its own API documentation directly, with no separate +// deploy step and no risk of drifting from the spec actually shipped. +package docs + +import "embed" + +//go:embed swagger.yaml index.html +var Assets embed.FS diff --git a/docs/swagger.yaml b/docs/swagger.yaml index a819954..334db2a 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -75,6 +75,24 @@ paths: "mac": "aa:bb:cc:dd:ee:ff" } ] + /api/v1/schema: + get: + summary: BNG Blaster configuration JSON schema. + description: >- + Get the JSON schema used to render and validate the "New Instance" config editor in the web UI. + responses: + 200: + description: ok + content: + application/json: + schema: + type: object + 404: + description: not found, schema not available + content: + text/plain: + schema: + type: string /api/v1/instances: get: summary: List of all instances. @@ -230,6 +248,242 @@ paths: text/plain: schema: type: string + /api/v1/instances/{instance_name}/_streams: + get: + summary: List traffic streams of a running instance. + description: >- + Get a paginated, optionally filtered view of the streams reported by the + bngblaster "stream-summary" control socket command. Used by the web UI's + virtual-scrolling stream table, which only ever requests the slice of rows + currently in (or near) its viewport. + parameters: + - name: instance_name + description: instance name of the bngblaster + in: path + required: true + example: sample + schema: + type: string + - name: offset + description: number of streams to skip + in: query + required: false + schema: + type: integer + default: 0 + - name: limit + description: maximum number of streams to return + in: query + required: false + schema: + type: integer + default: 50 + maximum: 500 + - name: session-id + in: query + required: false + schema: + type: integer + - name: session-group-id + in: query + required: false + schema: + type: integer + - name: flow-id + in: query + required: false + schema: + type: integer + - name: flow-id-min + description: lower bound of an explicit flow-id range; requires flow-id-max + in: query + required: false + schema: + type: integer + - name: flow-id-max + description: upper bound of an explicit flow-id range; requires flow-id-min + in: query + required: false + schema: + type: integer + - name: name + in: query + required: false + schema: + type: string + - name: interface + in: query + required: false + schema: + type: string + - name: direction + in: query + required: false + schema: + type: string + - name: state + in: query + required: false + schema: + type: string + enum: + - verified + - bidirectional-verified + - pending + responses: + 200: + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/streamsResponse' + 404: + description: not found, instance does not exist + content: + text/plain: + schema: + type: string + 412: + description: precondition failed, if instance is not running + content: + text/plain: + schema: + type: string + 500: + description: internal server error + content: + text/plain: + schema: + type: string + /api/v1/instances/{instance_name}/_sessions: + get: + summary: List sessions of a running instance. + description: >- + Get a paginated, optionally filtered view of the sessions reported by the + bngblaster "session-summary" control socket command. Used by the web UI's + virtual-scrolling session table, which only ever requests the slice of rows + currently in (or near) its viewport. + parameters: + - name: instance_name + description: instance name of the bngblaster + in: path + required: true + example: sample + schema: + type: string + - name: offset + description: number of sessions to skip + in: query + required: false + schema: + type: integer + default: 0 + - name: limit + description: maximum number of sessions to return + in: query + required: false + schema: + type: integer + default: 50 + maximum: 500 + - name: session-id + in: query + required: false + schema: + type: integer + - name: session-group-id + in: query + required: false + schema: + type: integer + - name: session-id-min + description: lower bound of an explicit session-id range; requires session-id-max + in: query + required: false + schema: + type: integer + - name: session-id-max + description: upper bound of an explicit session-id range; requires session-id-min + in: query + required: false + schema: + type: integer + responses: + 200: + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/sessionsResponse' + 404: + description: not found, instance does not exist + content: + text/plain: + schema: + type: string + 412: + description: precondition failed, if instance is not running + content: + text/plain: + schema: + type: string + 500: + description: internal server error + content: + text/plain: + schema: + type: string + /api/v1/instances/{instance_name}/_logs: + get: + summary: Tail the log of a running instance. + description: >- + Poll for log lines appended to the instance's run.log since a previous + call. Pass the "next_offset" from the previous response as the "offset" + query parameter to only receive newly appended lines. If the instance was + never started with logging enabled the response is an empty, EOF result + rather than an error. + parameters: + - name: instance_name + description: instance name of the bngblaster + in: path + required: true + example: sample + schema: + type: string + - name: offset + description: byte offset into run.log to read from + in: query + required: false + schema: + type: integer + default: 0 + - name: limit + description: maximum number of bytes to read + in: query + required: false + schema: + type: integer + default: 65536 + maximum: 1048576 + responses: + 200: + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/logsResponse' + 404: + description: not found, instance does not exist + content: + text/plain: + schema: + type: string + 500: + description: internal server error + content: + text/plain: + schema: + type: string /api/v1/instances/{instance_name}/_start: post: summary: Start an instance @@ -449,6 +703,59 @@ paths: text/plain: schema: type: string + /api/v1/instances/{instance_name}/_files: + get: + summary: List downloadable files. + description: >- + List the files present in an instance's config folder, used by the web UI's + download view. + parameters: + - name: instance_name + description: instance name of the parsable + in: path + required: true + example: sample + schema: + type: string + responses: + 200: + description: ok + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/instanceFile' + 404: + description: not found, instance does not exist + /api/v1/instances/{instance_name}/_files/{file_name}: + get: + summary: Download one of the files listed by _files. + description: >- + Serves a single file out of an instance's config folder. Unlike the fixed-name + route registered for the well-known result files, this accepts any file name + (e.g. user-uploaded files) since it only ever downloads names the _files + endpoint itself just listed. + parameters: + - name: instance_name + description: instance name of the parsable + in: path + required: true + example: sample + schema: + type: string + - name: file_name + description: name of the file to download, as returned by _files + in: path + required: true + example: config.json + schema: + type: string + responses: + 200: + description: ok, with the content type applicable for the specific file ending. + 404: + description: not found, instance or file does not exist /api/v1/instances/{instance_name}/{file_name}: get: summary: Download one of the output files. @@ -515,6 +822,247 @@ paths: components: schemas: + streamSummaryStream: + type: object + properties: + flow-id: + type: integer + name: + type: string + type: + type: string + sub-type: + type: string + direction: + type: string + enabled: + type: boolean + active: + type: boolean + verified: + type: boolean + interface: + type: string + tx-packets: + type: integer + tx-bytes: + type: integer + rx-packets: + type: integer + rx-bytes: + type: integer + rx-loss: + type: integer + tx-pps: + type: integer + rx-pps: + type: integer + session-id: + type: integer + session-traffic: + type: boolean + example: + { + "flow-id": 1, + "name": "stream1", + "type": "ipv4", + "sub-type": "raw", + "direction": "upstream", + "enabled": true, + "active": true, + "verified": true, + "interface": "bblA", + "tx-packets": 1000, + "tx-bytes": 64000, + "rx-packets": 1000, + "rx-bytes": 64000, + "rx-loss": 0, + "tx-pps": 100, + "rx-pps": 100, + "session-id": 1, + "session-traffic": false + } + streamsResponse: + type: object + properties: + total: + description: total number of streams matching the filters + type: integer + offset: + type: integer + limit: + type: integer + items: + type: array + items: + $ref: '#/components/schemas/streamSummaryStream' + example: + { + "total": 1, + "offset": 0, + "limit": 50, + "items": [ + { + "flow-id": 1, + "name": "stream1", + "type": "ipv4", + "sub-type": "raw", + "direction": "upstream", + "enabled": true, + "active": true, + "verified": true, + "interface": "bblA", + "tx-packets": 1000, + "tx-bytes": 64000, + "rx-packets": 1000, + "rx-bytes": 64000, + "rx-loss": 0, + "tx-pps": 100, + "rx-pps": 100, + "session-id": 1, + "session-traffic": false + } + ] + } + sessionSummarySession: + type: object + properties: + type: + type: string + session-id: + type: integer + pppoe-session-id: + type: integer + session-state: + type: string + flapped: + type: integer + interface: + type: string + outer-vlan: + type: integer + inner-vlan: + type: integer + mac: + type: string + server-mac: + type: string + username: + type: string + ipv4-address: + type: string + lcp-state: + type: string + ipcp-state: + type: string + ip6cp-state: + type: string + dhcpv6-state: + type: string + tx-packets: + type: integer + rx-packets: + type: integer + example: + { + "type": "pppoe", + "session-id": 1, + "pppoe-session-id": 1, + "session-state": "Established", + "flapped": 0, + "interface": "bblA", + "outer-vlan": 1, + "inner-vlan": 1, + "mac": "aa:bb:cc:dd:ee:ff", + "server-mac": "ff:ee:dd:cc:bb:aa", + "username": "user1@rtbrick.com", + "ipv4-address": "10.100.128.0", + "lcp-state": "Opened", + "ipcp-state": "Opened", + "ip6cp-state": "Opened", + "dhcpv6-state": "", + "tx-packets": 1000, + "rx-packets": 1000 + } + sessionsResponse: + type: object + properties: + total: + description: total number of sessions matching the filters + type: integer + offset: + type: integer + limit: + type: integer + items: + type: array + items: + $ref: '#/components/schemas/sessionSummarySession' + example: + { + "total": 1, + "offset": 0, + "limit": 50, + "items": [ + { + "type": "pppoe", + "session-id": 1, + "pppoe-session-id": 1, + "session-state": "Established", + "flapped": 0, + "interface": "bblA", + "outer-vlan": 1, + "inner-vlan": 1, + "mac": "aa:bb:cc:dd:ee:ff", + "server-mac": "ff:ee:dd:cc:bb:aa", + "username": "user1@rtbrick.com", + "ipv4-address": "10.100.128.0", + "lcp-state": "Opened", + "ipcp-state": "Opened", + "ip6cp-state": "Opened", + "dhcpv6-state": "", + "tx-packets": 1000, + "rx-packets": 1000 + } + ] + } + logsResponse: + type: object + properties: + offset: + description: byte offset the returned lines started at + type: integer + next_offset: + description: pass this as the "offset" query parameter on the next poll + type: integer + eof: + description: true if next_offset has reached the current end of the log file + type: boolean + lines: + type: array + items: + type: string + example: + { + "offset": 0, + "next_offset": 128, + "eof": true, + "lines": [ + "2025-01-01 00:00:00.000 INFO: bngblaster started" + ] + } + instanceFile: + type: object + properties: + name: + type: string + size: + type: integer + example: + { + "name": "run.log", + "size": 1024 + } commandResponse: type: object properties: diff --git a/logo.png b/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..fcb016eceede054caf06c5c35bce7e983d20003e GIT binary patch literal 76793 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 z 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 literal 0 HcmV?d00001 diff --git a/pkg/controller/model.go b/pkg/controller/model.go index d962b28..ee02f19 100644 --- a/pkg/controller/model.go +++ b/pkg/controller/model.go @@ -2,6 +2,8 @@ // Copyright (C) 2020-2025, RtBrick, Inc. package controller +import "context" + //go:generate moq -out repositorymock.go . Repository // Repository for managing the bng blaster. @@ -25,13 +27,27 @@ type Repository interface { // Running checks if a bngblaster instance is running. Running(name string) bool // Start the bngblaster instance with the given running configuration. - Start(name string, runningConfig RunningConfig) error + // It blocks until the instance is known to have come up or to have + // failed (see DefaultRepository.Start); ctx bounds that wait, so a + // caller whose client has gone away does not keep waiting. + Start(ctx context.Context, name string, runningConfig RunningConfig) error // Stop sends a SIGINT to the instance Stop(name string) // Kill sends a SIGKILL to the instance Kill(name string) // Command sends a request to the unix socket. Command(name string, command SocketCommand) ([]byte, error) + // Files lists the files present in an instance's config folder, for use + // by the web UI's downloads view. Internal run-control artifacts (the + // pid file and control socket) are excluded. + Files(name string) ([]InstanceFile, error) +} + +// InstanceFile describes one downloadable file inside an instance's config +// folder. +type InstanceFile struct { + Name string `json:"name"` + Size int64 `json:"size"` } // RunningConfig start configuration for the bngblaster. @@ -229,20 +245,63 @@ type A10nspInterfacesResponse struct { } `json:"a10nsp-interfaces"` } +// StreamSummaryStream describes a single stream as reported by the +// stream-summary socket command. +type StreamSummaryStream struct { + FlowId int `json:"flow-id"` + Name string `json:"name"` + Type string `json:"type"` + SubType string `json:"sub-type"` + Direction string `json:"direction"` + Enabled bool `json:"enabled"` + Active bool `json:"active"` + Verified bool `json:"verified"` + Interface string `json:"interface"` + TxPackets int `json:"tx-packets"` + TxBytes int `json:"tx-bytes"` + RxPackets int `json:"rx-packets"` + RxBytes int `json:"rx-bytes"` + RxLoss int `json:"rx-loss"` + TxPPS int `json:"tx-pps"` + RxPPS int `json:"rx-pps"` + SessionId int `json:"session-id"` + SessionTraffic bool `json:"session-traffic"` +} + // StreamSummaryResponse response for stream-summary socket command. type StreamSummaryResponse struct { - Code int `json:"code"` - Streams []struct { - FlowId int `json:"flow-id"` - Name string `json:"name"` - Type string `json:"type"` - SubType string `json:"sub-type"` - Direction string `json:"direction"` - TxPackets int `json:"tx-packets"` - TxBytes int `json:"tx-bytes"` - RxPackets int `json:"rx-packets"` - RxBytes int `json:"rx-bytes"` - RxLoss int `json:"rx-loss"` - SessionId int `json:"session-id"` - } `json:"stream-summary"` + Code int `json:"code"` + Streams []StreamSummaryStream `json:"stream-summary"` +} + +// SessionSummarySession describes a single session as reported by the +// session-summary socket command. Not every field is populated for every +// session type (e.g. dhcpv6-state/ip6cp-state only apply to some sessions), +// which is fine here since it only backs the summary table - the full, +// untyped session-info response backs the session detail view. +type SessionSummarySession struct { + Type string `json:"type"` + SessionId int `json:"session-id"` + PPPoESessionId int `json:"pppoe-session-id"` + SessionState string `json:"session-state"` + Flapped int `json:"flapped"` + Interface string `json:"interface"` + OuterVlan int `json:"outer-vlan"` + InnerVlan int `json:"inner-vlan"` + MAC string `json:"mac"` + ServerMAC string `json:"server-mac"` + Username string `json:"username"` + IPv4Address string `json:"ipv4-address"` + LCPState string `json:"lcp-state"` + IPCPState string `json:"ipcp-state"` + IP6CPState string `json:"ip6cp-state"` + DHCPv6State string `json:"dhcpv6-state"` + TxPackets int `json:"tx-packets"` + RxPackets int `json:"rx-packets"` +} + +// SessionSummaryResponse response for session-summary socket command. +type SessionSummaryResponse struct { + Code int `json:"code"` + Sessions []SessionSummarySession `json:"session-summary"` } diff --git a/pkg/controller/process.go b/pkg/controller/process.go index 8c7880b..1cb89ad 100644 --- a/pkg/controller/process.go +++ b/pkg/controller/process.go @@ -19,7 +19,11 @@ var ExecCommand = exec.Command // stdFile file that should be written with the stdout // errFile file that should be written with the stderr // args first argument will be the command to execute, all the rest are arguments that are used for this command. -func RunCommand(pidFile string, stdFile string, errFile string, args ...string) (chan bool, error) { +// The returned channel receives the command's exit error (nil on a clean +// exit) exactly once, once the process has terminated; it is buffered so a +// caller that stops waiting (e.g. after a startup grace period) never +// leaks the reporting goroutine. +func RunCommand(pidFile string, stdFile string, errFile string, args ...string) (chan error, error) { if len(args) == 0 { return nil, fmt.Errorf("at least one argument need to be specified") } @@ -44,12 +48,13 @@ func RunCommand(pidFile string, stdFile string, errFile string, args ...string) pid := cmd.Process.Pid _ = os.WriteFile(pidFile, []byte(fmt.Sprintf("%d", pid)), permission) - done := make(chan bool) + done := make(chan error, 1) go func() { - _ = cmd.Wait() + waitErr := cmd.Wait() _ = stdout.Close() _ = stderr.Close() _ = os.Remove(pidFile) + done <- waitErr close(done) log.Info().Str("command", strings.Join(args, " ")).Msg("stopped Command") }() diff --git a/pkg/controller/repository.go b/pkg/controller/repository.go index 62b1aef..1f09adf 100644 --- a/pkg/controller/repository.go +++ b/pkg/controller/repository.go @@ -3,6 +3,7 @@ package controller import ( + "context" "encoding/json" "errors" "fmt" @@ -11,6 +12,7 @@ import ( "os" "path" "strconv" + "strings" "syscall" "time" ) @@ -30,6 +32,16 @@ const ( bufferLength = 512 initialReceiveBufferLength = 20000 + // startupPollInterval is how often Start polls for the control socket + // while waiting to see whether bngblaster came up successfully. + startupPollInterval = 100 * time.Millisecond + // startupMaxWait bounds how long Start waits for the control socket to + // appear before giving up on detecting failure and reporting success + // anyway. A very large configuration can legitimately take a few + // seconds to come up, so this needs real headroom above the common + // "bad config, fails in milliseconds" case. + startupMaxWait = 30 * time.Second + // ConfigFilename configuration file of the blaster. ConfigFilename = "config.json" // runPidFilename file that contains the process id of the bngblaster instance if it is running. @@ -191,7 +203,7 @@ func (r *DefaultRepository) Running(name string) bool { } // Start implements Repository. -func (r *DefaultRepository) Start(name string, runningConfig RunningConfig) error { +func (r *DefaultRepository) Start(ctx context.Context, name string, runningConfig RunningConfig) error { if !r.Exists(name) { return ErrBlasterNotExists } @@ -211,12 +223,60 @@ func (r *DefaultRepository) Start(name string, runningConfig RunningConfig) erro return err } params := r.commandlineParameters(name, runningConfig) - _, err = RunCommand( + done, err := RunCommand( path.Join(folder, runPidFilename), path.Join(folder, RunStdOut), path.Join(folder, RunStdErr), params...) - return err + if err != nil { + return err + } + + // bngblaster only creates its control socket once it has fully come up + // (config parsed and validated, interfaces set up); a bad configuration + // instead makes it print an error and exit - usually within + // milliseconds, but a very large configuration can take a few seconds + // to either come up or fail. So: wait for whichever happens first, + // bounded by startupMaxWait so this can never hang the request forever, + // and by ctx so a caller that has gone away (a disconnected HTTP client) + // stops the wait immediately instead of pinning a goroutine for it. + // + // Note that returning early never stops the instance: it has been + // spawned either way, and giving up on *observing* the outcome only + // means the caller has to ask for the status separately. + sockFile := path.Join(folder, RunSockFilename) + deadline := time.Now().Add(startupMaxWait) + ticker := time.NewTicker(startupPollInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + // Caller gave up waiting; the instance itself keeps running. + return nil + case waitErr := <-done: + if waitErr == nil { + // Exited on its own without an error before creating a + // socket: not the failure case this is guarding against. + return nil + } + stderrContent, _ := os.ReadFile(path.Join(folder, RunStdErr)) + msg := strings.TrimSpace(string(stderrContent)) + if msg == "" { + msg = waitErr.Error() + } + return fmt.Errorf("%s", msg) + case <-ticker.C: + if _, statErr := os.Stat(sockFile); statErr == nil { + return nil + } + if time.Now().After(deadline) { + // Still running, just hasn't created its socket yet after a + // generous wait: report success rather than blocking (or + // misreporting failure) any longer. + return nil + } + } + } } // Stop implements Repository. @@ -285,6 +345,30 @@ func (r *DefaultRepository) config(name string) ([]byte, error) { return os.ReadFile(file) } +// Files implements Repository. +func (r *DefaultRepository) Files(name string) ([]InstanceFile, error) { + if !r.Exists(name) { + return nil, ErrBlasterNotExists + } + folder := path.Join(r.configFolder, name) + entries, err := os.ReadDir(folder) + if err != nil { + return nil, err + } + files := make([]InstanceFile, 0, len(entries)) + for _, entry := range entries { + if entry.IsDir() || entry.Name() == runPidFilename || entry.Name() == RunSockFilename { + continue + } + info, err := entry.Info() + if err != nil { + continue + } + files = append(files, InstanceFile{Name: entry.Name(), Size: info.Size()}) + } + return files, nil +} + // Command implements Repository. func (r *DefaultRepository) Command(name string, command SocketCommand) ([]byte, error) { if !r.Exists(name) { diff --git a/pkg/controller/repository_test.go b/pkg/controller/repository_test.go index 008c1c0..fec1e4a 100644 --- a/pkg/controller/repository_test.go +++ b/pkg/controller/repository_test.go @@ -3,12 +3,15 @@ package controller import ( + "context" "encoding/json" "fmt" "net" "os" + "os/exec" "os/signal" "path" + "strconv" "strings" "syscall" "testing" @@ -240,7 +243,7 @@ func TestDefaultRepository_Start(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if err := r.Start(tt.name, tt.runningConfig); (err != nil) != tt.wantErr { + if err := r.Start(context.Background(), tt.name, tt.runningConfig); (err != nil) != tt.wantErr { t.Fatalf("Start() error = %v, wantErr %v", err, tt.wantErr) } if tt.wantErr { @@ -438,3 +441,48 @@ func waitSig(t *testing.T, c <-chan os.Signal, sig os.Signal) { } t.Fatalf("timeout after %v waiting for %v", settleTime, sig) } + +func TestDefaultRepository_Start_returnsWhenTheCallerGivesUp(t *testing.T) { + // A process that stays alive without ever creating a control socket: + // exactly the case Start waits out, up to startupMaxWait. + defaultExecCommand := ExecCommand + ExecCommand = func(command string, args ...string) *exec.Cmd { + return exec.Command("sleep", "10") + } + defer func() { ExecCommand = defaultExecCommand }() + + // Its own config folder: Start writes run files into the instance folder, + // and the checked-in td/ fixtures are shared with the other tests. + configFolder := t.TempDir() + folder := path.Join(configFolder, "instance") + require.NoError(t, os.MkdirAll(folder, 0o700)) + r := NewDefaultRepository(WithConfigFolder(configFolder), WithExecutable("test")) + + // The caller's HTTP client has gone away. The instance has been spawned + // either way; only the observation of its outcome is abandoned, so Start + // must return at once instead of blocking for the full startup window. + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + started := time.Now() + done := make(chan error, 1) + go func() { done <- r.Start(ctx, "instance", RunningConfig{}) }() + + select { + case err := <-done: + require.NoError(t, err) + if waited := time.Since(started); waited >= 2*time.Second { + t.Fatalf("Start() waited %s: it ignored the cancelled context and "+ + "blocked on the process instead", waited) + } + case <-time.After(5 * time.Second): + t.Fatal("Start() ignored the cancelled context and kept waiting") + } + + // Leave no stray process behind. + if piddata, err := os.ReadFile(path.Join(folder, runPidFilename)); err == nil { + if pid, err := strconv.Atoi(string(piddata)); err == nil { + _ = syscall.Kill(pid, syscall.SIGKILL) + } + } +} diff --git a/pkg/controller/repositorymock.go b/pkg/controller/repositorymock.go index 3021845..7c0be53 100644 --- a/pkg/controller/repositorymock.go +++ b/pkg/controller/repositorymock.go @@ -4,6 +4,7 @@ package controller import ( + "context" "sync" ) @@ -38,6 +39,9 @@ var _ Repository = &RepositoryMock{} // ExistsFunc: func(name string) bool { // panic("mock out the Exists method") // }, +// FilesFunc: func(name string) ([]InstanceFile, error) { +// panic("mock out the Files method") +// }, // InstancesFunc: func() []string { // panic("mock out the Instances method") // }, @@ -47,7 +51,7 @@ var _ Repository = &RepositoryMock{} // RunningFunc: func(name string) bool { // panic("mock out the Running method") // }, -// StartFunc: func(name string, runningConfig RunningConfig) error { +// StartFunc: func(ctx context.Context, name string, runningConfig RunningConfig) error { // panic("mock out the Start method") // }, // StopFunc: func(name string) { @@ -81,6 +85,9 @@ type RepositoryMock struct { // ExistsFunc mocks the Exists method. ExistsFunc func(name string) bool + // FilesFunc mocks the Files method. + FilesFunc func(name string) ([]InstanceFile, error) + // InstancesFunc mocks the Instances method. InstancesFunc func() []string @@ -91,7 +98,7 @@ type RepositoryMock struct { RunningFunc func(name string) bool // StartFunc mocks the Start method. - StartFunc func(name string, runningConfig RunningConfig) error + StartFunc func(ctx context.Context, name string, runningConfig RunningConfig) error // StopFunc mocks the Stop method. StopFunc func(name string) @@ -131,6 +138,11 @@ type RepositoryMock struct { // Name is the name argument value. Name string } + // Files holds details about calls to the Files method. + Files []struct { + // Name is the name argument value. + Name string + } // Instances holds details about calls to the Instances method. Instances []struct { } @@ -146,6 +158,8 @@ type RepositoryMock struct { } // Start holds details about calls to the Start method. Start []struct { + // Ctx is the ctx argument value. + Ctx context.Context // Name is the name argument value. Name string // RunningConfig is the runningConfig argument value. @@ -164,6 +178,7 @@ type RepositoryMock struct { lockDelete sync.RWMutex lockExecutable sync.RWMutex lockExists sync.RWMutex + lockFiles sync.RWMutex lockInstances sync.RWMutex lockKill sync.RWMutex lockRunning sync.RWMutex @@ -388,6 +403,38 @@ func (mock *RepositoryMock) ExistsCalls() []struct { return calls } +// Files calls FilesFunc. +func (mock *RepositoryMock) Files(name string) ([]InstanceFile, error) { + if mock.FilesFunc == nil { + panic("RepositoryMock.FilesFunc: method is nil but Repository.Files was just called") + } + callInfo := struct { + Name string + }{ + Name: name, + } + mock.lockFiles.Lock() + mock.calls.Files = append(mock.calls.Files, callInfo) + mock.lockFiles.Unlock() + return mock.FilesFunc(name) +} + +// FilesCalls gets all the calls that were made to Files. +// Check the length with: +// +// len(mockedRepository.FilesCalls()) +func (mock *RepositoryMock) FilesCalls() []struct { + Name string +} { + var calls []struct { + Name string + } + mock.lockFiles.RLock() + calls = mock.calls.Files + mock.lockFiles.RUnlock() + return calls +} + // Instances calls InstancesFunc. func (mock *RepositoryMock) Instances() []string { if mock.InstancesFunc == nil { @@ -480,21 +527,23 @@ func (mock *RepositoryMock) RunningCalls() []struct { } // Start calls StartFunc. -func (mock *RepositoryMock) Start(name string, runningConfig RunningConfig) error { +func (mock *RepositoryMock) Start(ctx context.Context, name string, runningConfig RunningConfig) error { if mock.StartFunc == nil { panic("RepositoryMock.StartFunc: method is nil but Repository.Start was just called") } callInfo := struct { + Ctx context.Context Name string RunningConfig RunningConfig }{ + Ctx: ctx, Name: name, RunningConfig: runningConfig, } mock.lockStart.Lock() mock.calls.Start = append(mock.calls.Start, callInfo) mock.lockStart.Unlock() - return mock.StartFunc(name, runningConfig) + return mock.StartFunc(ctx, name, runningConfig) } // StartCalls gets all the calls that were made to Start. @@ -502,10 +551,12 @@ func (mock *RepositoryMock) Start(name string, runningConfig RunningConfig) erro // // len(mockedRepository.StartCalls()) func (mock *RepositoryMock) StartCalls() []struct { + Ctx context.Context Name string RunningConfig RunningConfig } { var calls []struct { + Ctx context.Context Name string RunningConfig RunningConfig } diff --git a/pkg/controller/td/exists/run.stderr b/pkg/controller/td/exists/run.stderr index e69de29..202a724 100755 --- a/pkg/controller/td/exists/run.stderr +++ b/pkg/controller/td/exists/run.stderr @@ -0,0 +1 @@ +warning: GOCOVERDIR not set, no coverage data emitted diff --git a/pkg/server/apidocs.go b/pkg/server/apidocs.go new file mode 100644 index 0000000..80a8029 --- /dev/null +++ b/pkg/server/apidocs.go @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (C) 2020-2025, RtBrick, Inc. +package server + +import ( + "net/http" + + "github.com/rtbrick/bngblaster-controller/docs" +) + +// registerAPIDocsRoutes exposes the embedded OpenAPI/Swagger definition and +// a Swagger UI viewer for it at /docs/. It is independent of the web UI +// (registered regardless of WithUI) since it documents the REST API itself. +func (s *Server) registerAPIDocsRoutes() { + s.router.Path("/docs").Methods(http.MethodGet).Handler(http.RedirectHandler("/docs/", http.StatusMovedPermanently)) + s.router.Path("/docs/").Methods(http.MethodGet).Handler(s.apiDocsAsset("index.html", "text/html; charset=utf-8")) + s.router.Path("/docs/swagger.yaml").Methods(http.MethodGet).Handler(s.apiDocsAsset("swagger.yaml", "application/yaml")) +} + +func (s *Server) apiDocsAsset(name, ct string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + content, err := docs.Assets.ReadFile(name) + if err != nil { + JSONError(w, "api docs not available", http.StatusInternalServerError) + return + } + w.Header().Set(contentType, ct) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(content) + } +} diff --git a/pkg/server/cache.go b/pkg/server/cache.go new file mode 100644 index 0000000..a0eddf1 --- /dev/null +++ b/pkg/server/cache.go @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (C) 2020-2025, RtBrick, Inc. +package server + +import ( + "sync" + "time" +) + +const ( + // summaryCacheTTL is how long a summary response fetched from the + // bngblaster control socket is reused for. The stream/session list views + // issue one HTTP request per visible range while the user scrolls; + // without a short-lived cache each of those would open a new unix socket + // connection and re-run the (potentially large) summary command. + summaryCacheTTL = 2 * time.Second + + // maxSummaryCacheEntries bounds a cache, which is keyed per instance + // *and* per distinct filter combination (see streamFilters). It is reset + // wholesale once this many entries accumulate rather than tracked with + // per-entry eviction, since it only exists to make short-lived UI polling + // cheap, not to be a long-lived store. + maxSummaryCacheEntries = 64 +) + +type cacheEntry[T any] struct { + fetchedAt time.Time + value T + err error +} + +// inflight is a single in-progress fetch that later arrivals for the same key +// wait on instead of issuing a duplicate control-socket round-trip. +type inflight[T any] struct { + done chan struct{} + value T + err error +} + +// summaryCache memoizes control-socket responses per instance (and filter +// combination) for a short period. It exists purely to make server-side +// pagination cheap; it is not a source of truth and always expires quickly. +// +// Concurrent misses for the same key are coalesced into a single fetch: the +// UI polls every 2s with a 2s TTL, so without coalescing every poll would be +// a miss by construction and N open browser tabs would mean N socket +// round-trips for identical data. +type summaryCache[T any] struct { + mu sync.Mutex + entries map[string]cacheEntry[T] + calls map[string]*inflight[T] +} + +func newSummaryCache[T any]() *summaryCache[T] { + return &summaryCache[T]{ + entries: map[string]cacheEntry[T]{}, + calls: map[string]*inflight[T]{}, + } +} + +func (c *summaryCache[T]) get(key string, fetch func() (T, error)) (T, error) { + c.mu.Lock() + if entry, ok := c.entries[key]; ok && time.Since(entry.fetchedAt) < summaryCacheTTL { + c.mu.Unlock() + return entry.value, entry.err + } + // Somebody else is already fetching exactly this: wait for their result + // rather than opening a second socket for the same data. + if call, ok := c.calls[key]; ok { + c.mu.Unlock() + <-call.done + return call.value, call.err + } + call := &inflight[T]{done: make(chan struct{})} + c.calls[key] = call + c.mu.Unlock() + + call.value, call.err = fetch() + + c.mu.Lock() + if len(c.entries) >= maxSummaryCacheEntries { + // Keyed per instance *and* filter combination, so an interactive user + // trying out several filters can otherwise grow this unboundedly over + // a long session. This isn't a source of truth, so wiping it wholesale + // is safe - anyone still polling just refetches on their next request. + c.entries = map[string]cacheEntry[T]{} + } + c.entries[key] = cacheEntry[T]{fetchedAt: time.Now(), value: call.value, err: call.err} + delete(c.calls, key) + c.mu.Unlock() + + close(call.done) + return call.value, call.err +} + +// invalidate drops every entry belonging to one instance. Called whenever an +// instance's lifecycle changes (start/stop/kill/delete) so the UI does not +// keep being served up to summaryCacheTTL of stale rows from the previous +// run, and so a deleted instance leaves nothing behind. +func (c *summaryCache[T]) invalidate(instance string) { + c.mu.Lock() + defer c.mu.Unlock() + for key := range c.entries { + if key == instance || (len(key) > len(instance) && key[:len(instance)] == instance && key[len(instance)] == '|') { + delete(c.entries, key) + } + } +} diff --git a/pkg/server/files.go b/pkg/server/files.go new file mode 100644 index 0000000..dd6d4aa --- /dev/null +++ b/pkg/server/files.go @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (C) 2020-2025, RtBrick, Inc. +package server + +import ( + "encoding/json" + "net/http" + "path/filepath" + "strconv" + + "github.com/gorilla/mux" +) + +// files lists the downloadable files present in an instance's config +// folder, used by the web UI's "Download" view. +func (s *Server) files() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + instanceVariable := mux.Vars(r)[instanceNameParameter] + instance := cleanPathVariable(instanceVariable) + if !s.repository.Exists(instance) { + JSONNotFound(w, r) + return + } + + files, err := s.repository.Files(instance) + if err != nil { + JSONError(w, "not able to list files", http.StatusInternalServerError) + return + } + + w.Header().Set(contentType, applicationJSON) + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(files) + } +} + +// fileDownload serves a single file out of an instance's config folder. +// Unlike the fixed-name route registered for the well-known result files, +// this accepts any file name (e.g. user-uploaded files) since it only ever +// downloads names the files() endpoint itself just listed - path traversal +// is prevented by only ever taking the base component of the requested name. +// +// The folder holds arbitrary user-uploaded content, so the response is +// forced to a download: without "Content-Disposition: attachment" plus +// "X-Content-Type-Options: nosniff", an uploaded .html or .svg file would be +// served inline and execute script in the controller's own origin - a stored +// cross-site scripting vector against every other user of this UI. +func (s *Server) fileDownload() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + instanceVariable := mux.Vars(r)[instanceNameParameter] + instance := cleanPathVariable(instanceVariable) + if !s.repository.Exists(instance) { + JSONNotFound(w, r) + return + } + file := filepath.Base(mux.Vars(r)["file_name"]) + w.Header().Set("Content-Disposition", "attachment; filename="+strconv.Quote(file)) + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set(contentType, "application/octet-stream") + http.ServeFile(w, r, filepath.Join(s.repository.ConfigFolder(), instance, file)) + } +} diff --git a/pkg/server/files_test.go b/pkg/server/files_test.go new file mode 100644 index 0000000..7d564ec --- /dev/null +++ b/pkg/server/files_test.go @@ -0,0 +1,138 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (C) 2020-2025, RtBrick, Inc. +package server + +import ( + "bytes" + "encoding/json" + "mime/multipart" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/rtbrick/bngblaster-controller/pkg/controller" +) + +func TestServer_files(t *testing.T) { + repository := &controller.RepositoryMock{ + ConfigFolderFunc: func() string { return configFolder }, + ExistsFunc: func(name string) bool { return true }, + FilesFunc: func(name string) ([]controller.InstanceFile, error) { + return []controller.InstanceFile{{Name: "run_report.json", Size: 12}}, nil + }, + } + handler := NewServer(repository) + + recorder := doGet(t, handler, "/api/v1/instances/test/_files") + require.Equal(t, http.StatusOK, recorder.Code) + + var files []controller.InstanceFile + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &files)) + require.Equal(t, []controller.InstanceFile{{Name: "run_report.json", Size: 12}}, files) +} + +func TestServer_fileDownload_isForcedToADownload(t *testing.T) { + folder := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(folder, "test"), 0o700)) + // The instance folder holds arbitrary uploaded content. Served inline, + // this would execute script in the controller's own origin. + payload := "" + require.NoError(t, os.WriteFile(filepath.Join(folder, "test", "evil.html"), []byte(payload), 0o600)) + + repository := &controller.RepositoryMock{ + ConfigFolderFunc: func() string { return folder }, + ExistsFunc: func(name string) bool { return true }, + } + handler := NewServer(repository) + + recorder := doGet(t, handler, "/api/v1/instances/test/_files/evil.html") + require.Equal(t, http.StatusOK, recorder.Code) + require.Equal(t, payload, recorder.Body.String()) + require.Equal(t, `attachment; filename="evil.html"`, recorder.Header().Get("Content-Disposition")) + require.Equal(t, "nosniff", recorder.Header().Get("X-Content-Type-Options")) + require.Equal(t, "application/octet-stream", recorder.Header().Get("Content-Type"), + "the browser must never be told this is renderable HTML") +} + +func TestServer_fileDownload_missingInstance(t *testing.T) { + repository := &controller.RepositoryMock{ + ConfigFolderFunc: func() string { return configFolder }, + ExistsFunc: func(name string) bool { return false }, + } + handler := NewServer(repository) + + recorder := doGet(t, handler, "/api/v1/instances/test/_files/whatever.json") + require.Equal(t, http.StatusNotFound, recorder.Code) +} + +// uploadRequest builds a multipart upload carrying the given (possibly +// hostile) filename. +func uploadRequest(t *testing.T, instance, filename, content string) *http.Request { + t.Helper() + var body bytes.Buffer + writer := multipart.NewWriter(&body) + part, err := writer.CreateFormFile("file", filename) + require.NoError(t, err) + _, err = part.Write([]byte(content)) + require.NoError(t, err) + require.NoError(t, writer.Close()) + + request := httptest.NewRequest(http.MethodPost, "/api/v1/instances/"+instance+"/_upload", &body) + request.Header.Set("Content-Type", writer.FormDataContentType()) + return request +} + +func TestServer_uploadFile_cannotEscapeInstanceFolder(t *testing.T) { + root := t.TempDir() + folder := filepath.Join(root, "configs") + require.NoError(t, os.MkdirAll(filepath.Join(folder, "test"), 0o700)) + + repository := &controller.RepositoryMock{ + ConfigFolderFunc: func() string { return folder }, + AllowUploadFunc: func() bool { return true }, + ExistsFunc: func(name string) bool { return true }, + } + handler := NewServer(repository) + + // net/http strips the directory components itself, and the handler takes + // the base name again on top of that. This pins the resulting guarantee: + // whatever a client puts in the multipart filename, the upload lands + // inside the instance folder and nowhere else. + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, uploadRequest(t, "test", "../../pwned.txt", "payload")) + require.Equal(t, http.StatusOK, recorder.Code) + + _, err := os.Stat(filepath.Join(root, "pwned.txt")) + require.True(t, os.IsNotExist(err), "upload must not escape the instance folder") + _, err = os.Stat(filepath.Join(folder, "pwned.txt")) + require.True(t, os.IsNotExist(err), "upload must not escape the instance folder") + + // It lands under its base name inside the instance folder instead. + written, err := os.ReadFile(filepath.Join(folder, "test", "pwned.txt")) + require.NoError(t, err) + require.Equal(t, "payload", string(written)) +} + +func TestServer_uploadFile_storesPlainNameUnchanged(t *testing.T) { + folder := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(folder, "test"), 0o700)) + + repository := &controller.RepositoryMock{ + ConfigFolderFunc: func() string { return folder }, + AllowUploadFunc: func() bool { return true }, + ExistsFunc: func(name string) bool { return true }, + } + handler := NewServer(repository) + + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, uploadRequest(t, "test", "streams.json", `{"streams":[]}`)) + require.Equal(t, http.StatusOK, recorder.Code) + + written, err := os.ReadFile(filepath.Join(folder, "test", "streams.json")) + require.NoError(t, err) + require.Equal(t, `{"streams":[]}`, string(written)) +} diff --git a/pkg/server/logs.go b/pkg/server/logs.go new file mode 100644 index 0000000..006299d --- /dev/null +++ b/pkg/server/logs.go @@ -0,0 +1,207 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (C) 2020-2025, RtBrick, Inc. +package server + +import ( + "bufio" + "bytes" + "encoding/json" + "io" + "net/http" + "os" + "path" + "syscall" + + "github.com/gorilla/mux" + + "github.com/rtbrick/bngblaster-controller/pkg/controller" +) + +const ( + defaultLogReadLimit = 64 * 1024 + maxLogReadLimit = 1 << 20 +) + +// logsResponse is returned by the log tail endpoint. NextOffset should be +// passed back as the "offset" query parameter on the following poll so the +// viewer only ever receives newly appended log lines. +// +// Generation identifies the log file itself (its inode), not its contents. +// Starting an instance deletes and recreates run.log, so an offset carried +// over from a previous run points into a file that no longer exists: if the +// new log has already grown past that offset, a plain size comparison cannot +// detect the rotation and everything written before it is silently skipped. +// A client must therefore reset its offset to 0 whenever Generation changes. +type logsResponse struct { + Generation uint64 `json:"generation"` + Offset int64 `json:"offset"` + NextOffset int64 `json:"next_offset"` + EOF bool `json:"eof"` + Lines []string `json:"lines"` +} + +// generationPrefixLen is how many bytes from the start of a log file are +// hashed into its generation. Enough to cover the first log line, which +// carries a timestamp and therefore differs between runs. +const generationPrefixLen = 256 + +// logGeneration returns an identifier that changes whenever the log file a +// client is reading is replaced by a different one. +// +// File metadata cannot answer this. Starting an instance deletes run.log and +// immediately recreates it, which on ext4 reuses the just-freed inode - and +// with it the recorded birth time - so neither identifies the new file as +// distinct. What reliably differs is the content: the first log line carries +// a timestamp from the run that wrote it. Hashing the file's leading bytes +// together with its inode therefore answers the question actually being +// asked, which is "is this still the file whose offset I am holding?". +func logGeneration(f *os.File, info os.FileInfo) uint64 { + prefix := make([]byte, generationPrefixLen) + n, err := f.ReadAt(prefix, 0) + if err != nil && err != io.EOF { + n = 0 + } + prefix = prefix[:n] + + // FNV-1a over the inode followed by the content prefix. + const ( + offset64 = 14695981039346656037 + prime64 = 1099511628211 + ) + hash := uint64(offset64) + mix := func(b byte) { + hash ^= uint64(b) + hash *= prime64 + } + if stat, ok := info.Sys().(*syscall.Stat_t); ok { + for shift := 0; shift < 64; shift += 8 { + mix(byte(stat.Ino >> shift)) + } + } + for _, b := range prefix { + mix(b) + } + return hash +} + +// logs is a STUB handler for the instance log viewer. +// +// bngblaster does not currently expose a socket command to stream log +// messages, so this implementation tails the run.log file that the +// bngblaster process writes to when started with logging enabled +// (see RunningConfig.Logging). The UI polls this endpoint with the +// "offset" it last received, which keeps the request cheap regardless of +// how large the log file grows. +// +// Once bngblaster gains a native "log" (or similar) socket command capable +// of streaming structured log messages, this handler should be replaced +// with one that forwards to repository.Command the same way s.streams() +// does, without requiring any change to the frontend's polling contract +// (offset/next_offset/eof/lines). +func (s *Server) logs() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + instanceVariable := mux.Vars(r)[instanceNameParameter] + instance := cleanPathVariable(instanceVariable) + if !s.repository.Exists(instance) { + JSONNotFound(w, r) + return + } + + offset := int64(parseNonNegativeIntQuery(r, "offset", 0)) + limit := parseNonNegativeIntQuery(r, "limit", defaultLogReadLimit) + if limit <= 0 || limit > maxLogReadLimit { + limit = defaultLogReadLimit + } + + file := path.Join(s.repository.ConfigFolder(), instance, controller.RunLogFilename) + resp, err := tailLogFile(file, offset, limit) + if err != nil { + // No log file yet (e.g. instance never started with logging + // enabled) is not an error from the UI's perspective. + if os.IsNotExist(err) { + // No log file yet: generation 0 tells the client to reset, + // so a stale offset from a previous run cannot survive. + w.Header().Set(contentType, applicationJSON) + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(logsResponse{Offset: 0, NextOffset: 0, EOF: true, Lines: []string{}}) + return + } + JSONError(w, "not able to read log", http.StatusInternalServerError) + return + } + + w.Header().Set(contentType, applicationJSON) + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(resp) + } +} + +func tailLogFile(file string, offset int64, limit int) (logsResponse, error) { + f, err := os.Open(file) + if err != nil { + return logsResponse{}, err + } + defer func() { + _ = f.Close() + }() + + info, err := f.Stat() + if err != nil { + return logsResponse{}, err + } + size := info.Size() + generation := logGeneration(f, info) + if offset > size { + // File was truncated/rotated since the last poll; restart from 0. + offset = 0 + } + + toRead := size - offset + if toRead > int64(limit) { + toRead = int64(limit) + } + if toRead < 0 { + toRead = 0 + } + + if _, err := f.Seek(offset, io.SeekStart); err != nil { + return logsResponse{}, err + } + + buf := make([]byte, toRead) + n, err := io.ReadFull(f, buf) + if err != nil && err != io.EOF && err != io.ErrUnexpectedEOF { + return logsResponse{}, err + } + buf = buf[:n] + nextOffset := offset + int64(n) + + // Only emit complete lines; keep any trailing partial line for the next + // poll by not advancing nextOffset past the last newline. + lastNewline := bytes.LastIndexByte(buf, '\n') + complete := buf + if lastNewline == -1 { + complete = nil + } else { + complete = buf[:lastNewline+1] + nextOffset = offset + int64(lastNewline+1) + } + + var lines []string + scanner := bufio.NewScanner(bytes.NewReader(complete)) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + for scanner.Scan() { + lines = append(lines, scanner.Text()) + } + if lines == nil { + lines = []string{} + } + + return logsResponse{ + Generation: generation, + Offset: offset, + NextOffset: nextOffset, + EOF: nextOffset >= size, + Lines: lines, + }, nil +} diff --git a/pkg/server/logs_test.go b/pkg/server/logs_test.go new file mode 100644 index 0000000..580fc84 --- /dev/null +++ b/pkg/server/logs_test.go @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (C) 2020-2025, RtBrick, Inc. +package server + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func writeLogFile(t *testing.T, path, content string) { + t.Helper() + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) +} + +func TestTailLogFile_readsCompleteLinesOnly(t *testing.T) { + file := filepath.Join(t.TempDir(), "run.log") + writeLogFile(t, file, "first\nsecond\npartial") + + resp, err := tailLogFile(file, 0, defaultLogReadLimit) + require.NoError(t, err) + + // "partial" has no terminating newline yet, so it must be held back for + // the next poll rather than emitted as a truncated line. + require.Equal(t, []string{"first", "second"}, resp.Lines) + require.Equal(t, int64(0), resp.Offset) + require.Equal(t, int64(len("first\nsecond\n")), resp.NextOffset) + require.False(t, resp.EOF) + require.NotZero(t, resp.Generation) +} + +func TestTailLogFile_resumesFromOffset(t *testing.T) { + file := filepath.Join(t.TempDir(), "run.log") + writeLogFile(t, file, "first\nsecond\n") + + first, err := tailLogFile(file, 0, defaultLogReadLimit) + require.NoError(t, err) + require.Equal(t, []string{"first", "second"}, first.Lines) + require.True(t, first.EOF) + + writeLogFile(t, file, "first\nsecond\nthird\n") + + second, err := tailLogFile(file, first.NextOffset, defaultLogReadLimit) + require.NoError(t, err) + require.Equal(t, []string{"third"}, second.Lines) + require.True(t, second.EOF) +} + +func TestTailLogFile_rewindsWhenFileShrank(t *testing.T) { + file := filepath.Join(t.TempDir(), "run.log") + writeLogFile(t, file, "aaaa\nbbbb\ncccc\n") + long, err := tailLogFile(file, 0, defaultLogReadLimit) + require.NoError(t, err) + + // A restart recreates run.log; a shorter replacement is detectable from + // the size alone and must restart from the beginning. + writeLogFile(t, file, "new\n") + resp, err := tailLogFile(file, long.NextOffset, defaultLogReadLimit) + require.NoError(t, err) + require.Equal(t, int64(0), resp.Offset) + require.Equal(t, []string{"new"}, resp.Lines) +} + +func TestTailLogFile_generationChangesWhenFileIsReplaced(t *testing.T) { + file := filepath.Join(t.TempDir(), "run.log") + writeLogFile(t, file, "one\ntwo\n") + before, err := tailLogFile(file, 0, defaultLogReadLimit) + require.NoError(t, err) + + // The case a size comparison cannot catch: the file is replaced and the + // replacement is already longer than the offset carried over from the + // previous run. Only the generation reveals that the offset is stale. + require.NoError(t, os.Remove(file)) + writeLogFile(t, file, "alpha\nbravo\ncharlie\ndelta\n") + + after, err := tailLogFile(file, before.NextOffset, defaultLogReadLimit) + require.NoError(t, err) + require.NotEqual(t, before.Generation, after.Generation, + "a recreated log file must report a different generation") +} + +func TestTailLogFile_respectsLimit(t *testing.T) { + file := filepath.Join(t.TempDir(), "run.log") + writeLogFile(t, file, "aaaa\nbbbb\ncccc\n") + + // Only "aaaa\n" fits whole inside a 7 byte budget. + resp, err := tailLogFile(file, 0, 7) + require.NoError(t, err) + require.Equal(t, []string{"aaaa"}, resp.Lines) + require.Equal(t, int64(5), resp.NextOffset) + require.False(t, resp.EOF) +} + +func TestTailLogFile_missingFile(t *testing.T) { + _, err := tailLogFile(filepath.Join(t.TempDir(), "absent.log"), 0, defaultLogReadLimit) + require.True(t, os.IsNotExist(err)) +} diff --git a/pkg/server/options.go b/pkg/server/options.go new file mode 100644 index 0000000..d052b3c --- /dev/null +++ b/pkg/server/options.go @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (C) 2020-2025, RtBrick, Inc. +package server + +import "net/http" + +// DefaultSchemaPath is the default location of the bngblaster configuration +// JSON schema, used to drive the "New Instance" config editor in the web UI. +const DefaultSchemaPath = "/etc/bngblaster/bngblaster-config.json" + +// AuthMiddleware is the function signature used to plug in authentication. +// It wraps a http.Handler and is invoked for every request routed through +// the server, before the UI and API handlers. +type AuthMiddleware func(http.Handler) http.Handler + +// noopAuthMiddleware is the default AuthMiddleware. It performs no +// authentication and simply forwards the request. Replace it with +// WithAuthMiddleware once a login/authentication mechanism is required. +func noopAuthMiddleware(next http.Handler) http.Handler { + return next +} + +// Option configures optional behavior of the Server. +type Option func(*Server) + +// WithUI enables or disables serving the embedded web UI on "/". +// Enabled by default. +func WithUI(enabled bool) Option { + return func(s *Server) { + s.enableUI = enabled + } +} + +// WithInterfacesAPI enables or disables the "/api/v1/interfaces" endpoint +// which reports the network interfaces available on the host. Enabled by +// default. +func WithInterfacesAPI(enabled bool) Option { + return func(s *Server) { + s.enableInterfaces = enabled + } +} + +// WithSchemaPath sets the file system location of the bngblaster +// configuration JSON schema served via "/api/v1/schema". Defaults to +// DefaultSchemaPath. +func WithSchemaPath(path string) Option { + return func(s *Server) { + s.schemaPath = path + } +} + +// WithAuthMiddleware installs the given middleware in front of every route +// (UI and API alike). This is the extension point intended for adding +// login/session/token based authentication later without restructuring the +// routing table. +func WithAuthMiddleware(mw AuthMiddleware) Option { + return func(s *Server) { + if mw != nil { + s.authMiddleware = mw + } + } +} diff --git a/pkg/server/overview.go b/pkg/server/overview.go new file mode 100644 index 0000000..dff39da --- /dev/null +++ b/pkg/server/overview.go @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (C) 2020-2025, RtBrick, Inc. +package server + +import ( + "encoding/json" + "net/http" + + "github.com/gorilla/mux" + + "github.com/rtbrick/bngblaster-controller/pkg/controller" +) + +// overviewCommands are the control socket commands aggregated by the +// instance overview endpoint. The key of each entry in the response is the +// command name, which is also the key bngblaster wraps its payload in. +var overviewCommands = []string{ + "session-counters", + "network-interfaces", + "access-interfaces", + "a10nsp-interfaces", + "test-info", +} + +// overview aggregates every control socket command the instance detail view +// polls into a single cached response: GET .../_overview +// +// The Session Overview tab previously issued one request per command every +// two seconds, and the header duration badge a fifth, so a single open +// browser tab meant five uncached unix socket round-trips every two seconds +// and N tabs meant 5*N. Serving them from one endpoint behind the shared +// summary cache collapses that to one round-trip per command per cache +// period regardless of how many viewers are watching. +// +// A command that fails individually (unsupported by this bngblaster build, +// or simply not applicable) yields a null value for its key rather than +// failing the whole response - the UI hides the corresponding section. +func (s *Server) overview() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + instanceVariable := mux.Vars(r)[instanceNameParameter] + instance := cleanPathVariable(instanceVariable) + if !s.repository.Exists(instance) { + JSONNotFound(w, r) + return + } + + result, err := s.overviewCache.get(instance, func() (map[string]json.RawMessage, error) { + out := map[string]json.RawMessage{} + var firstErr error + for _, command := range overviewCommands { + payload, err := s.repository.Command(instance, controller.SocketCommand{Command: command}) + if err != nil { + // ErrBlasterNotRunning applies to every command equally, so + // remember it and report it once the loop is done; anything + // else is treated as "this command is unavailable". + if firstErr == nil { + firstErr = err + } + continue + } + var envelope map[string]json.RawMessage + if err := json.Unmarshal(payload, &envelope); err != nil { + continue + } + if value, ok := envelope[command]; ok { + out[command] = value + } + } + if len(out) == 0 && firstErr != nil { + return nil, firstErr + } + return out, nil + }) + if err == controller.ErrBlasterNotRunning { + JSONError(w, "instance is not running", http.StatusPreconditionFailed) + return + } + if err != nil { + JSONError(w, "not able to fetch instance overview", http.StatusInternalServerError) + return + } + + // Always emit every key so the client can tell "not reported" from + // "not requested" without knowing the command list itself. + response := map[string]json.RawMessage{} + for _, command := range overviewCommands { + response[command] = result[command] + } + + w.Header().Set(contentType, applicationJSON) + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(response) + } +} diff --git a/pkg/server/server.go b/pkg/server/server.go index 74a3296..45477b4 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -14,6 +14,7 @@ import ( "path" "path/filepath" "strings" + "sync" "github.com/gorilla/mux" "github.com/rs/zerolog/log" @@ -43,6 +44,28 @@ type Server struct { router *mux.Router prom *controller.Prom repository controller.Repository + + // enableUI toggles serving the embedded web UI on "/". + enableUI bool + // enableInterfaces toggles the "/api/v1/interfaces" endpoint. + enableInterfaces bool + // schemaPath is the file system location of the bngblaster config schema. + schemaPath string + // authMiddleware is invoked for every request. It is a no-op unless + // WithAuthMiddleware is used, and is the extension point for plugging + // in authentication/login later. + authMiddleware AuthMiddleware + + streamCache *summaryCache[[]controller.StreamSummaryStream] + sessionCache *summaryCache[[]controller.SessionSummarySession] + overviewCache *summaryCache[map[string]json.RawMessage] + + // assetVersion is appended to every embedded UI asset URL as a cache + // busting query parameter, so a browser can cache them aggressively yet + // never run a stale app.js against a newer controller. It is resolved + // lazily because Version is assigned after NewServer returns. + assetVersion string + assetVersionOnce sync.Once } // InterfaceInfo holds the information about a network interface. @@ -62,12 +85,22 @@ type VersionInfo struct { } // NewServer is a constructor function for Server. -func NewServer(repository controller.Repository) *Server { +func NewServer(repository controller.Repository, opts ...Option) *Server { r := &Server{ - Version: "dev", - router: mux.NewRouter(), - prom: controller.NewProm(repository), - repository: repository, + Version: "dev", + router: mux.NewRouter(), + prom: controller.NewProm(repository), + repository: repository, + enableUI: true, + enableInterfaces: true, + schemaPath: DefaultSchemaPath, + authMiddleware: noopAuthMiddleware, + streamCache: newSummaryCache[[]controller.StreamSummaryStream](), + sessionCache: newSummaryCache[[]controller.SessionSummarySession](), + overviewCache: newSummaryCache[map[string]json.RawMessage](), + } + for _, opt := range opts { + opt(r) } r.routes() return r @@ -90,6 +123,11 @@ func loggingMiddleware(next http.Handler) http.Handler { func (s *Server) routes() { const instanceURL = "/api/v1/instances/{instance_name}" s.router.Use(loggingMiddleware) + // authMiddleware is a no-op unless WithAuthMiddleware(...) was supplied. + // It sits in front of both the UI and the API so a future login system + // can be introduced here without touching individual handlers. + s.router.Use(mux.MiddlewareFunc(s.authMiddleware)) + // Expose the registered metrics via HTTP. s.router.Path("/metrics").Methods(http.MethodGet).Handler(promhttp.HandlerFor( s.prom.Registry, @@ -98,8 +136,18 @@ func (s *Server) routes() { }, )) s.router.Path("/api/v1/version").Methods(http.MethodGet).Handler(s.version()) - s.router.Path("/api/v1/interfaces").Methods(http.MethodGet).Handler(s.interfaces()) + s.router.Path("/api/v1/schema").Methods(http.MethodGet).Handler(s.schema()) + s.registerAPIDocsRoutes() + if s.enableInterfaces { + s.router.Path("/api/v1/interfaces").Methods(http.MethodGet).Handler(s.interfaces()) + } s.router.Path("/api/v1/instances").Methods(http.MethodGet).Handler(s.instances()) + s.router.Path(instanceURL + "/_overview").Methods(http.MethodGet).Handler(s.overview()) + s.router.Path(instanceURL + "/_streams").Methods(http.MethodGet).Handler(s.streams()) + s.router.Path(instanceURL + "/_sessions").Methods(http.MethodGet).Handler(s.sessions()) + s.router.Path(instanceURL + "/_logs").Methods(http.MethodGet).Handler(s.logs()) + s.router.Path(instanceURL + "/_files").Methods(http.MethodGet).Handler(s.files()) + s.router.Path(instanceURL + "/_files/{file_name}").Methods(http.MethodGet).Handler(s.fileDownload()) s.router. Path( fmt.Sprintf("%s/{file_name:%s|%s|%s|%s|%s|%s|%s}", @@ -121,6 +169,10 @@ func (s *Server) routes() { s.router.Path(instanceURL + "/_kill").Methods(http.MethodPost).Handler(s.kill()) s.router.Path(instanceURL + "/_command").Methods(http.MethodPost).Handler(s.command()) s.router.Path(instanceURL + "/_upload").Methods(http.MethodPost).Handler(s.uploadFile()) + + if s.enableUI { + s.registerUIRoutes() + } } func (s *Server) fileServing(directory string) http.HandlerFunc { @@ -132,15 +184,51 @@ func (s *Server) fileServing(directory string) http.HandlerFunc { } } +// instanceDetail is one entry of the detailed instance listing. +type instanceDetail struct { + Name string `json:"name"` + Status string `json:"status"` +} + +// instances lists the configured instances. By default this is the plain +// array of names it has always been; "?detail=true" instead returns each +// name together with its status. +// +// The dashboard refreshes its table every few seconds and needs the status +// of every instance, which previously meant one request for the list plus +// one per instance on every refresh. The detailed form collapses that into +// a single request. func (s *Server) instances() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { instances := s.repository.Instances() w.Header().Set(contentType, applicationJSON) w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(instances) + if r.URL.Query().Get("detail") != "true" { + _ = json.NewEncoder(w).Encode(instances) + return + } + details := make([]instanceDetail, 0, len(instances)) + for _, name := range instances { + status := "stopped" + if s.repository.Running(name) { + status = "started" + } + details = append(details, instanceDetail{Name: name, Status: status}) + } + _ = json.NewEncoder(w).Encode(details) } } +// invalidateInstanceCaches drops every cached summary belonging to an +// instance. Called whenever its lifecycle changes so a start/stop/kill/delete +// is reflected immediately instead of after the cache period, and so a +// deleted instance leaves nothing cached behind it. +func (s *Server) invalidateInstanceCaches(instance string) { + s.streamCache.invalidate(instance) + s.sessionCache.invalidate(instance) + s.overviewCache.invalidate(instance) +} + // getReadableInterfaceFlags converts interface flags to a readable format. func getReadableInterfaceFlags(flags net.Flags) []string { var readableFlags []string @@ -235,6 +323,21 @@ func (s *Server) version() http.HandlerFunc { } } +// schema serves the bngblaster configuration JSON schema used by the web UI +// to render and validate the "New Instance" config editor. +func (s *Server) schema() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + content, err := os.ReadFile(s.schemaPath) + if err != nil { + JSONError(w, "schema not available", http.StatusNotFound) + return + } + w.Header().Set(contentType, applicationJSON) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(content) + } +} + func (s *Server) create() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { instanceVariable := mux.Vars(r)[instanceNameParameter] @@ -288,6 +391,7 @@ func (s *Server) delete() http.HandlerFunc { instanceVariable := mux.Vars(r)[instanceNameParameter] instance := cleanPathVariable(instanceVariable) status := http.StatusNoContent + s.invalidateInstanceCaches(instance) err := s.repository.Delete(instance) if err == controller.ErrBlasterRunning { JSONError(w, errInstanceIsRunning, http.StatusPreconditionFailed) @@ -314,7 +418,8 @@ func (s *Server) start() http.HandlerFunc { status := http.StatusNoContent - err = s.repository.Start(instance, runningConfig) + s.invalidateInstanceCaches(instance) + err = s.repository.Start(r.Context(), instance, runningConfig) if err == controller.ErrBlasterNotExists { JSONNotFound(w, r) return @@ -324,7 +429,7 @@ func (s *Server) start() http.HandlerFunc { return } if err != nil { - JSONError(w, "not able to start", http.StatusInternalServerError) + JSONError(w, err.Error(), http.StatusInternalServerError) return } w.WriteHeader(status) @@ -337,6 +442,7 @@ func (s *Server) stop() http.HandlerFunc { instance := cleanPathVariable(instanceVariable) status := http.StatusAccepted s.repository.Stop(instance) + s.invalidateInstanceCaches(instance) w.WriteHeader(status) } } @@ -347,6 +453,7 @@ func (s *Server) kill() http.HandlerFunc { instance := cleanPathVariable(instanceVariable) status := http.StatusAccepted s.repository.Kill(instance) + s.invalidateInstanceCaches(instance) w.WriteHeader(status) } } @@ -423,7 +530,12 @@ func (s *Server) uploadFile() http.HandlerFunc { } defer file.Close() - filePath := filepath.Join(s.repository.ConfigFolder(), instance, handler.Filename) + // Only ever the base name. net/http already strips directory + // components from a multipart filename (RFC 7578 requires it), so + // this is belt and braces - but the guarantee that an upload cannot + // escape the instance folder is worth stating at the point where the + // path is built rather than relying on a caller's behavior. + filePath := filepath.Join(s.repository.ConfigFolder(), instance, filepath.Base(handler.Filename)) destFile, err := os.Create(filePath) if err != nil { diff --git a/pkg/server/server_test.go b/pkg/server/server_test.go index b53a5c6..ffd03ec 100644 --- a/pkg/server/server_test.go +++ b/pkg/server/server_test.go @@ -3,6 +3,7 @@ package server import ( + "context" "fmt" "net/http" "net/http/httptest" @@ -246,7 +247,7 @@ func TestServer_start(t *testing.T) { name: "error", resultStart: fmt.Errorf("other error"), body: &controller.RunningConfig{}, - wantBody: "not able to start", + wantBody: "other error", want: http.StatusInternalServerError, }, } @@ -256,7 +257,7 @@ func TestServer_start(t *testing.T) { ConfigFolderFunc: func() string { return configFolder }, - StartFunc: func(name string, config controller.RunningConfig) error { + StartFunc: func(_ context.Context, name string, config controller.RunningConfig) error { return tt.resultStart }, } diff --git a/pkg/server/sessions.go b/pkg/server/sessions.go new file mode 100644 index 0000000..4621e46 --- /dev/null +++ b/pkg/server/sessions.go @@ -0,0 +1,170 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (C) 2020-2025, RtBrick, Inc. +package server + +import ( + "encoding/json" + "fmt" + "net/http" + + "github.com/gorilla/mux" + + "github.com/rtbrick/bngblaster-controller/pkg/controller" +) + +const ( + defaultSessionPageSize = 50 + maxSessionPageSize = 500 +) + +// sessionFilters mirrors the filter arguments accepted by the bngblaster +// "session-summary" control socket command. +type sessionFilters struct { + SessionID *int + SessionGroupID *int + SessionIDMin *int + SessionIDMax *int +} + +func parseSessionFilters(r *http.Request) sessionFilters { + f := sessionFilters{} + f.SessionID = parseOptionalIntQuery(r, "session-id") + f.SessionGroupID = parseOptionalIntQuery(r, "session-group-id") + f.SessionIDMin = parseOptionalIntQuery(r, "session-id-min") + f.SessionIDMax = parseOptionalIntQuery(r, "session-id-max") + return f +} + +// cacheKey is a stable string encoding of the filter set, used as (part of) +// the session-summary cache key. +func (f sessionFilters) cacheKey() string { + key := "" + if f.SessionID != nil { + key += fmt.Sprintf("|session-id=%d", *f.SessionID) + } + if f.SessionGroupID != nil { + key += fmt.Sprintf("|session-group-id=%d", *f.SessionGroupID) + } + if f.SessionIDMin != nil { + key += fmt.Sprintf("|session-id-min=%d", *f.SessionIDMin) + } + if f.SessionIDMax != nil { + key += fmt.Sprintf("|session-id-max=%d", *f.SessionIDMax) + } + return key +} + +// arguments builds the "arguments" object sent alongside the +// "session-summary" socket command. +func (f sessionFilters) arguments() map[string]interface{} { + args := map[string]interface{}{} + if f.SessionID != nil { + args["session-id"] = *f.SessionID + } + if f.SessionGroupID != nil { + args["session-group-id"] = *f.SessionGroupID + } + if f.SessionIDMin != nil { + args["session-id-min"] = *f.SessionIDMin + } + if f.SessionIDMax != nil { + args["session-id-max"] = *f.SessionIDMax + } + return args +} + +// sessionsResponse is the paginated view of session-summary returned to the +// UI, mirroring streamsResponse's "floating range" contract for the +// virtual-scrolling session table. +type sessionsResponse struct { + Total int `json:"total"` + Offset int `json:"offset"` + Limit int `json:"limit"` + Items []controller.SessionSummarySession `json:"items"` +} + +// sessions implements the "floating range" pagination endpoint backing the +// virtual-scrolling session table: GET .../_sessions?offset=&limit= +func (s *Server) sessions() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + instanceVariable := mux.Vars(r)[instanceNameParameter] + instance := cleanPathVariable(instanceVariable) + if !s.repository.Exists(instance) { + JSONNotFound(w, r) + return + } + + offset := parseNonNegativeIntQuery(r, "offset", 0) + limit := parseNonNegativeIntQuery(r, "limit", defaultSessionPageSize) + if limit <= 0 { + limit = defaultSessionPageSize + } + if limit > maxSessionPageSize { + limit = maxSessionPageSize + } + + filters := parseSessionFilters(r) + // See streams(): "window=1" distinguishes a range the virtual scroller + // derived from its scroll position from one the user typed in. + windowed := r.URL.Query().Get("window") == "1" && filters.SessionIDMin != nil && filters.SessionIDMax != nil + cacheKey := instance + filters.cacheKey() + + sessionsData, err := s.sessionCache.get(cacheKey, func() ([]controller.SessionSummarySession, error) { + result, err := s.repository.Command(instance, controller.SocketCommand{ + Command: "session-summary", + Arguments: filters.arguments(), + }) + if err != nil { + return nil, err + } + var parsed controller.SessionSummaryResponse + if err := json.Unmarshal(result, &parsed); err != nil { + return nil, err + } + return parsed.Sessions, nil + }) + if err == controller.ErrBlasterNotRunning { + JSONError(w, "instance is not running", http.StatusPreconditionFailed) + return + } + if err != nil { + JSONError(w, "not able to fetch session summary", http.StatusInternalServerError) + return + } + + var resp sessionsResponse + if windowed { + // Same reasoning as streams(): a session-id range generated by the + // UI's virtual-scroll window is already exactly the slice about to + // be rendered, and Offset is the absolute row index it starts at. + resp = sessionsResponse{ + Total: len(sessionsData), + Offset: *filters.SessionIDMin - 1, + Limit: limit, + Items: sessionsData, + } + } else { + // Everything else - including a user-entered session-id range - is + // plain offset/limit pagination over the filtered result. + total := len(sessionsData) + start := offset + if start > total { + start = total + } + end := start + limit + if end > total { + end = total + } + resp = sessionsResponse{ + Total: total, + Offset: start, + Limit: limit, + Items: sessionsData[start:end], + } + } + + w.Header().Set(contentType, applicationJSON) + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(resp) + } +} diff --git a/pkg/server/streams.go b/pkg/server/streams.go new file mode 100644 index 0000000..9cf6f9c --- /dev/null +++ b/pkg/server/streams.go @@ -0,0 +1,254 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (C) 2020-2025, RtBrick, Inc. +package server + +import ( + "encoding/json" + "fmt" + "net/http" + "strconv" + + "github.com/gorilla/mux" + + "github.com/rtbrick/bngblaster-controller/pkg/controller" +) + +const ( + defaultStreamPageSize = 50 + maxStreamPageSize = 500 +) + +// streamFilters mirrors the filter arguments accepted by the bngblaster +// "stream-summary" control socket command, allowing the stream table to +// narrow down the (potentially large) stream list server-side instead of +// downloading everything and filtering in the browser. +type streamFilters struct { + SessionID *int + SessionGroupID *int + FlowID *int + FlowIDMin *int + FlowIDMax *int + Name string + Interface string + Direction string + // State is one of "verified", "bidirectional-verified", "pending" or "" + // (any), matching the mutually exclusive verified-only / + // bidirectional-verified-only / pending-only socket command arguments. + State string +} + +func parseStreamFilters(r *http.Request) streamFilters { + q := r.URL.Query() + f := streamFilters{ + Name: q.Get("name"), + Interface: q.Get("interface"), + Direction: q.Get("direction"), + State: q.Get("state"), + } + f.SessionID = parseOptionalIntQuery(r, "session-id") + f.SessionGroupID = parseOptionalIntQuery(r, "session-group-id") + f.FlowID = parseOptionalIntQuery(r, "flow-id") + f.FlowIDMin = parseOptionalIntQuery(r, "flow-id-min") + f.FlowIDMax = parseOptionalIntQuery(r, "flow-id-max") + return f +} + +func parseOptionalIntQuery(r *http.Request, name string) *int { + raw := r.URL.Query().Get(name) + if raw == "" { + return nil + } + v, err := strconv.Atoi(raw) + if err != nil { + return nil + } + return &v +} + +// cacheKey is a stable string encoding of the filter set, used as (part of) +// the stream-summary cache key. +func (f streamFilters) cacheKey() string { + key := "" + if f.SessionID != nil { + key += fmt.Sprintf("|session-id=%d", *f.SessionID) + } + if f.SessionGroupID != nil { + key += fmt.Sprintf("|session-group-id=%d", *f.SessionGroupID) + } + if f.FlowID != nil { + key += fmt.Sprintf("|flow-id=%d", *f.FlowID) + } + if f.FlowIDMin != nil { + key += fmt.Sprintf("|flow-id-min=%d", *f.FlowIDMin) + } + if f.FlowIDMax != nil { + key += fmt.Sprintf("|flow-id-max=%d", *f.FlowIDMax) + } + if f.Name != "" { + key += "|name=" + f.Name + } + if f.Interface != "" { + key += "|interface=" + f.Interface + } + if f.Direction != "" { + key += "|direction=" + f.Direction + } + if f.State != "" { + key += "|state=" + f.State + } + return key +} + +// arguments builds the "arguments" object sent alongside the +// "stream-summary" socket command. +func (f streamFilters) arguments() map[string]interface{} { + args := map[string]interface{}{} + if f.SessionID != nil { + args["session-id"] = *f.SessionID + } + if f.SessionGroupID != nil { + args["session-group-id"] = *f.SessionGroupID + } + if f.FlowID != nil { + args["flow-id"] = *f.FlowID + } + if f.FlowIDMin != nil { + args["flow-id-min"] = *f.FlowIDMin + } + if f.FlowIDMax != nil { + args["flow-id-max"] = *f.FlowIDMax + } + if f.Name != "" { + args["name"] = f.Name + } + if f.Interface != "" { + args["interface"] = f.Interface + } + if f.Direction != "" { + args["direction"] = f.Direction + } + switch f.State { + case "verified": + args["verified-only"] = true + case "bidirectional-verified": + args["bidirectional-verified-only"] = true + case "pending": + args["pending-only"] = true + } + return args +} + +// streamsResponse is the paginated view of stream-summary returned to the UI. +// This is the "floating range" contract used by the virtual scrolling stream +// table: the client only ever asks for the slice of rows currently in (or +// near) the viewport instead of downloading the entire stream list. +type streamsResponse struct { + Total int `json:"total"` + Offset int `json:"offset"` + Limit int `json:"limit"` + Items []controller.StreamSummaryStream `json:"items"` +} + +// streams implements the "floating range" pagination endpoint backing the +// virtual-scrolling stream table: GET .../_streams?offset=&limit= +func (s *Server) streams() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + instanceVariable := mux.Vars(r)[instanceNameParameter] + instance := cleanPathVariable(instanceVariable) + if !s.repository.Exists(instance) { + JSONNotFound(w, r) + return + } + + offset := parseNonNegativeIntQuery(r, "offset", 0) + limit := parseNonNegativeIntQuery(r, "limit", defaultStreamPageSize) + if limit <= 0 { + limit = defaultStreamPageSize + } + if limit > maxStreamPageSize { + limit = maxStreamPageSize + } + + filters := parseStreamFilters(r) + // "window=1" marks a flow-id range the UI's virtual scroller derived + // from its scroll position rather than one the user typed into the + // filter panel. The two need different pagination semantics (see + // below), and only the client knows which is which. + windowed := r.URL.Query().Get("window") == "1" && filters.FlowIDMin != nil && filters.FlowIDMax != nil + cacheKey := instance + filters.cacheKey() + + streamsData, err := s.streamCache.get(cacheKey, func() ([]controller.StreamSummaryStream, error) { + result, err := s.repository.Command(instance, controller.SocketCommand{ + Command: "stream-summary", + Arguments: filters.arguments(), + }) + if err != nil { + return nil, err + } + var parsed controller.StreamSummaryResponse + if err := json.Unmarshal(result, &parsed); err != nil { + return nil, err + } + return parsed.Streams, nil + }) + if err == controller.ErrBlasterNotRunning { + JSONError(w, "instance is not running", http.StatusPreconditionFailed) + return + } + if err != nil { + JSONError(w, "not able to fetch stream summary", http.StatusInternalServerError) + return + } + + var resp streamsResponse + if windowed { + // The flow-id range was generated by the UI's virtual-scroll + // window, not typed by a user: it asked bngblaster for exactly the + // slice of streams it is about to render, so that slice is returned + // as-is. Offset is the absolute row index the slice starts at, + // which for a sequentially assigned flow-id chain is FlowIDMin-1. + resp = streamsResponse{ + Total: len(streamsData), + Offset: *filters.FlowIDMin - 1, + Limit: limit, + Items: streamsData, + } + } else { + // Everything else - including a user-entered flow-id range - is + // plain offset/limit pagination over the filtered result. Offset + // is a row index into that result and Total is its full length, so + // the client can size a scrollbar for the filtered list correctly. + total := len(streamsData) + start := offset + if start > total { + start = total + } + end := start + limit + if end > total { + end = total + } + resp = streamsResponse{ + Total: total, + Offset: start, + Limit: limit, + Items: streamsData[start:end], + } + } + + w.Header().Set(contentType, applicationJSON) + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(resp) + } +} + +func parseNonNegativeIntQuery(r *http.Request, name string, def int) int { + raw := r.URL.Query().Get(name) + if raw == "" { + return def + } + v, err := strconv.Atoi(raw) + if err != nil || v < 0 { + return def + } + return v +} diff --git a/pkg/server/summary_test.go b/pkg/server/summary_test.go new file mode 100644 index 0000000..013ac27 --- /dev/null +++ b/pkg/server/summary_test.go @@ -0,0 +1,389 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (C) 2020-2025, RtBrick, Inc. +package server + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/rtbrick/bngblaster-controller/pkg/controller" +) + +// streamSummaryJSON builds a stream-summary socket response holding streams +// with the given flow ids. +func streamSummaryJSON(flowIDs ...int) []byte { + streams := make([]controller.StreamSummaryStream, 0, len(flowIDs)) + for _, id := range flowIDs { + streams = append(streams, controller.StreamSummaryStream{FlowId: id, Name: fmt.Sprintf("stream-%d", id)}) + } + payload, err := json.Marshal(controller.StreamSummaryResponse{Code: 200, Streams: streams}) + if err != nil { + panic(err) + } + return payload +} + +func sessionSummaryJSON(sessionIDs ...int) []byte { + sessions := make([]controller.SessionSummarySession, 0, len(sessionIDs)) + for _, id := range sessionIDs { + sessions = append(sessions, controller.SessionSummarySession{SessionId: id}) + } + payload, err := json.Marshal(controller.SessionSummaryResponse{Code: 200, Sessions: sessions}) + if err != nil { + panic(err) + } + return payload +} + +func rangeOf(from, to int) []int { + ids := make([]int, 0, to-from+1) + for id := from; id <= to; id++ { + ids = append(ids, id) + } + return ids +} + +func doGet(t *testing.T, handler http.Handler, target string) *httptest.ResponseRecorder { + t.Helper() + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, target, nil)) + return recorder +} + +func TestServer_streams_windowedRangeKeepsAbsoluteOffset(t *testing.T) { + repository := &controller.RepositoryMock{ + ConfigFolderFunc: func() string { return configFolder }, + ExistsFunc: func(name string) bool { return true }, + CommandFunc: func(name string, command controller.SocketCommand) ([]byte, error) { + // bngblaster has already narrowed the result to the requested range. + return streamSummaryJSON(rangeOf(101, 110)...), nil + }, + } + handler := NewServer(repository) + + recorder := doGet(t, handler, + "/api/v1/instances/test/_streams?offset=100&limit=10&flow-id-min=101&flow-id-max=110&window=1") + require.Equal(t, http.StatusOK, recorder.Code) + + var resp streamsResponse + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &resp)) + // A scroller window is returned as-is, offset being the absolute row it + // starts at, so the client can position it without re-deriving anything. + require.Equal(t, 100, resp.Offset) + require.Len(t, resp.Items, 10) + require.Equal(t, 101, resp.Items[0].FlowId) +} + +func TestServer_streams_userRangeIsPaginatedAsAFlatList(t *testing.T) { + repository := &controller.RepositoryMock{ + ConfigFolderFunc: func() string { return configFolder }, + ExistsFunc: func(name string) bool { return true }, + CommandFunc: func(name string, command controller.SocketCommand) ([]byte, error) { + return streamSummaryJSON(rangeOf(100001, 100010)...), nil + }, + } + handler := NewServer(repository) + + // The same range typed into the filter panel: without "window=1" this is + // an ordinary filtered list. Reporting offset 100000 with a total of 10 + // (as it once did) made the client render a multi-million pixel spacer + // above ten rows and claim to be showing "rows 100001-100010 of 10". + recorder := doGet(t, handler, + "/api/v1/instances/test/_streams?offset=0&limit=5&flow-id-min=100001&flow-id-max=100010") + require.Equal(t, http.StatusOK, recorder.Code) + + var resp streamsResponse + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &resp)) + require.Equal(t, 0, resp.Offset, "offset must be a row index, not a flow id") + require.Equal(t, 10, resp.Total, "total must be the full filtered count") + require.Len(t, resp.Items, 5) + require.Equal(t, 100001, resp.Items[0].FlowId) + + // ... and the second page continues from where the first left off. + recorder = doGet(t, handler, + "/api/v1/instances/test/_streams?offset=5&limit=5&flow-id-min=100001&flow-id-max=100010") + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &resp)) + require.Equal(t, 5, resp.Offset) + require.Equal(t, 10, resp.Total) + require.Equal(t, 100006, resp.Items[0].FlowId) +} + +func TestServer_streams_onlyMinBoundIsNotAWindow(t *testing.T) { + repository := &controller.RepositoryMock{ + ConfigFolderFunc: func() string { return configFolder }, + ExistsFunc: func(name string) bool { return true }, + CommandFunc: func(name string, command controller.SocketCommand) ([]byte, error) { + return streamSummaryJSON(rangeOf(50, 59)...), nil + }, + } + handler := NewServer(repository) + + recorder := doGet(t, handler, "/api/v1/instances/test/_streams?offset=2&limit=3&flow-id-min=50&window=1") + require.Equal(t, http.StatusOK, recorder.Code) + + var resp streamsResponse + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &resp)) + // window=1 needs both bounds to mean anything; a half-open range falls + // back to plain pagination rather than being returned unsliced. + require.Equal(t, 2, resp.Offset) + require.Equal(t, 10, resp.Total) + require.Len(t, resp.Items, 3) + require.Equal(t, 52, resp.Items[0].FlowId) +} + +func TestServer_streams_offsetBeyondEnd(t *testing.T) { + repository := &controller.RepositoryMock{ + ConfigFolderFunc: func() string { return configFolder }, + ExistsFunc: func(name string) bool { return true }, + CommandFunc: func(name string, command controller.SocketCommand) ([]byte, error) { + return streamSummaryJSON(1, 2, 3), nil + }, + } + handler := NewServer(repository) + + recorder := doGet(t, handler, "/api/v1/instances/test/_streams?offset=99&limit=10") + require.Equal(t, http.StatusOK, recorder.Code) + + var resp streamsResponse + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &resp)) + require.Equal(t, 3, resp.Total) + require.Equal(t, 3, resp.Offset) + require.Empty(t, resp.Items) +} + +func TestServer_streams_notRunning(t *testing.T) { + repository := &controller.RepositoryMock{ + ConfigFolderFunc: func() string { return configFolder }, + ExistsFunc: func(name string) bool { return true }, + CommandFunc: func(name string, command controller.SocketCommand) ([]byte, error) { + return nil, controller.ErrBlasterNotRunning + }, + } + handler := NewServer(repository) + + recorder := doGet(t, handler, "/api/v1/instances/test/_streams") + require.Equal(t, http.StatusPreconditionFailed, recorder.Code) +} + +func TestServer_sessions_windowedRangeKeepsAbsoluteOffset(t *testing.T) { + repository := &controller.RepositoryMock{ + ConfigFolderFunc: func() string { return configFolder }, + ExistsFunc: func(name string) bool { return true }, + CommandFunc: func(name string, command controller.SocketCommand) ([]byte, error) { + return sessionSummaryJSON(rangeOf(21, 30)...), nil + }, + } + handler := NewServer(repository) + + recorder := doGet(t, handler, + "/api/v1/instances/test/_sessions?offset=20&limit=10&session-id-min=21&session-id-max=30&window=1") + require.Equal(t, http.StatusOK, recorder.Code) + + var resp sessionsResponse + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &resp)) + require.Equal(t, 20, resp.Offset) + require.Len(t, resp.Items, 10) +} + +func TestServer_sessions_userRangeIsPaginatedAsAFlatList(t *testing.T) { + repository := &controller.RepositoryMock{ + ConfigFolderFunc: func() string { return configFolder }, + ExistsFunc: func(name string) bool { return true }, + CommandFunc: func(name string, command controller.SocketCommand) ([]byte, error) { + return sessionSummaryJSON(rangeOf(9001, 9010)...), nil + }, + } + handler := NewServer(repository) + + recorder := doGet(t, handler, + "/api/v1/instances/test/_sessions?offset=0&limit=4&session-id-min=9001&session-id-max=9010") + require.Equal(t, http.StatusOK, recorder.Code) + + var resp sessionsResponse + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &resp)) + require.Equal(t, 0, resp.Offset) + require.Equal(t, 10, resp.Total) + require.Len(t, resp.Items, 4) +} + +func TestSummaryCache_coalescesConcurrentMisses(t *testing.T) { + cache := newSummaryCache[int]() + var fetches int32 + release := make(chan struct{}) + + var wg sync.WaitGroup + results := make([]int, 20) + for i := range results { + wg.Add(1) + go func(idx int) { + defer wg.Done() + value, err := cache.get("instance", func() (int, error) { + atomic.AddInt32(&fetches, 1) + <-release + return 42, nil + }) + require.NoError(t, err) + results[idx] = value + }(i) + } + + // Let every goroutine reach the cache before the single fetch completes. + for atomic.LoadInt32(&fetches) == 0 { + } + close(release) + wg.Wait() + + require.Equal(t, int32(1), atomic.LoadInt32(&fetches), + "concurrent misses for one key must share a single control socket round-trip") + for _, value := range results { + require.Equal(t, 42, value) + } +} + +func TestSummaryCache_servesWithinTTLAndInvalidates(t *testing.T) { + cache := newSummaryCache[int]() + fetches := 0 + fetch := func() (int, error) { + fetches++ + return fetches, nil + } + + first, err := cache.get("inst", fetch) + require.NoError(t, err) + require.Equal(t, 1, first) + + second, err := cache.get("inst", fetch) + require.NoError(t, err) + require.Equal(t, 1, second, "a hit within the TTL must not refetch") + require.Equal(t, 1, fetches) + + cache.invalidate("inst") + third, err := cache.get("inst", fetch) + require.NoError(t, err) + require.Equal(t, 2, third, "invalidate must force the next read to refetch") +} + +func TestSummaryCache_invalidateIsScopedToTheInstance(t *testing.T) { + cache := newSummaryCache[int]() + fetches := map[string]int{} + fetchFor := func(key string) func() (int, error) { + return func() (int, error) { fetches[key]++; return fetches[key], nil } + } + + // "foo" plus one of its filter combinations, and a similarly named + // instance that must not be caught by the prefix match. + _, _ = cache.get("foo", fetchFor("foo")) + _, _ = cache.get("foo|flow-id-min=1", fetchFor("foo-filtered")) + _, _ = cache.get("foobar", fetchFor("foobar")) + + cache.invalidate("foo") + + _, _ = cache.get("foo", fetchFor("foo")) + _, _ = cache.get("foo|flow-id-min=1", fetchFor("foo-filtered")) + _, _ = cache.get("foobar", fetchFor("foobar")) + + require.Equal(t, 2, fetches["foo"]) + require.Equal(t, 2, fetches["foo-filtered"]) + require.Equal(t, 1, fetches["foobar"], "an instance with a shared name prefix must be left alone") +} + +func TestServer_overview_aggregatesCommandsIntoOneCall(t *testing.T) { + var issued []string + var mu sync.Mutex + repository := &controller.RepositoryMock{ + ConfigFolderFunc: func() string { return configFolder }, + ExistsFunc: func(name string) bool { return true }, + CommandFunc: func(name string, command controller.SocketCommand) ([]byte, error) { + mu.Lock() + issued = append(issued, command.Command) + mu.Unlock() + switch command.Command { + case "session-counters": + return []byte(`{"status":"ok","code":200,"session-counters":{"sessions":7}}`), nil + case "test-info": + return []byte(`{"status":"ok","code":200,"test-info":{"duration":12,"state":"active"}}`), nil + case "network-interfaces": + return []byte(`{"status":"ok","code":200,"network-interfaces":[{"name":"eth0"}]}`), nil + } + // The remaining interface commands are unsupported by this build. + return nil, fmt.Errorf("unknown command") + }, + } + handler := NewServer(repository) + + recorder := doGet(t, handler, "/api/v1/instances/test/_overview") + require.Equal(t, http.StatusOK, recorder.Code) + + var resp map[string]json.RawMessage + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &resp)) + require.JSONEq(t, `{"sessions":7}`, string(resp["session-counters"])) + require.JSONEq(t, `{"duration":12,"state":"active"}`, string(resp["test-info"])) + require.JSONEq(t, `[{"name":"eth0"}]`, string(resp["network-interfaces"])) + // A command that fails individually yields null rather than failing the + // whole response, so the UI simply hides that section. + require.Equal(t, "null", string(resp["access-interfaces"])) + require.Equal(t, overviewCommands, issued) + + // The second request inside the cache period must not reach the socket + // again: this is the whole point of aggregating them. + mu.Lock() + issued = nil + mu.Unlock() + require.Equal(t, http.StatusOK, doGet(t, handler, "/api/v1/instances/test/_overview").Code) + require.Empty(t, issued) +} + +func TestServer_overview_notRunning(t *testing.T) { + repository := &controller.RepositoryMock{ + ConfigFolderFunc: func() string { return configFolder }, + ExistsFunc: func(name string) bool { return true }, + CommandFunc: func(name string, command controller.SocketCommand) ([]byte, error) { + return nil, controller.ErrBlasterNotRunning + }, + } + handler := NewServer(repository) + + recorder := doGet(t, handler, "/api/v1/instances/test/_overview") + require.Equal(t, http.StatusPreconditionFailed, recorder.Code) +} + +func TestServer_instances_detail(t *testing.T) { + repository := &controller.RepositoryMock{ + ConfigFolderFunc: func() string { return configFolder }, + InstancesFunc: func() []string { return []string{"alpha", "beta"} }, + RunningFunc: func(name string) bool { return name == "beta" }, + } + handler := NewServer(repository) + + // Without the flag the response is the plain name array it has always been. + recorder := doGet(t, handler, "/api/v1/instances") + require.Equal(t, http.StatusOK, recorder.Code) + require.JSONEq(t, `["alpha","beta"]`, recorder.Body.String()) + + // With it, one request replaces the list plus one status call per instance. + recorder = doGet(t, handler, "/api/v1/instances?detail=true") + require.Equal(t, http.StatusOK, recorder.Code) + require.JSONEq(t, + `[{"name":"alpha","status":"stopped"},{"name":"beta","status":"started"}]`, + recorder.Body.String()) +} + +func TestServer_instances_detailOnEmptyListIsAnArray(t *testing.T) { + repository := &controller.RepositoryMock{ + ConfigFolderFunc: func() string { return configFolder }, + InstancesFunc: func() []string { return nil }, + } + handler := NewServer(repository) + + recorder := doGet(t, handler, "/api/v1/instances?detail=true") + require.Equal(t, http.StatusOK, recorder.Code) + require.Equal(t, "[]", strings.TrimSpace(recorder.Body.String())) +} diff --git a/pkg/server/ui.go b/pkg/server/ui.go new file mode 100644 index 0000000..22d3137 --- /dev/null +++ b/pkg/server/ui.go @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (C) 2020-2025, RtBrick, Inc. +package server + +import ( + "bytes" + "embed" + "fmt" + "html/template" + "io/fs" + "net/http" + "strings" + "sync" + "time" +) + +// webUIAssets embeds the built-in single-page application that ships inside +// the bngblaster-controller binary. It is intentionally dependency-free +// (vanilla HTML/CSS/JS) so the controller remains a single static binary +// with no build step or external assets required at install time. +// +//go:embed webui/index.html webui/static +var webUIAssets embed.FS + +// processStartToken distinguishes one controller process from another when +// no meaningful release version is available (a "dev" build). It gives the +// asset version something that still changes across restarts, so a developer +// rebuilding the UI is never served a stale asset from the browser cache. +var processStartToken = fmt.Sprintf("dev-%d", time.Now().UnixNano()) + +// registerUIRoutes wires the embedded web UI into the router. It is only +// called when the UI is enabled (see WithUI). Static assets are served from +// "/static/...", the application shell from "/". +func (s *Server) registerUIRoutes() { + staticFS, err := fs.Sub(webUIAssets, "webui/static") + if err != nil { + // Cannot happen: the sub-directory is embedded at compile time. + panic(err) + } + + fileServer := http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))) + s.router.PathPrefix("/static/").Methods(http.MethodGet).Handler(s.cacheControl(fileServer)) + s.router.Path("/").Methods(http.MethodGet).Handler(s.index()) + s.router.Path("/favicon.ico").Methods(http.MethodGet).Handler(s.favicon()) +} + +// uiAssetVersion is the cache busting token appended to every asset URL the +// application shell emits. It is derived from the controller version, which +// is assigned after NewServer returns, so it is resolved lazily on first use +// and then kept for the lifetime of the process. +func (s *Server) uiAssetVersion() string { + s.assetVersionOnce.Do(func() { + version := strings.TrimSpace(s.Version) + if version == "" || version == "dev" { + s.assetVersion = processStartToken + return + } + s.assetVersion = version + }) + return s.assetVersion +} + +// indexTemplate renders the application shell. The only substitution is +// AssetVersion, used to version every asset URL. +var indexTemplate = sync.OnceValues(func() (*template.Template, error) { + content, err := webUIAssets.ReadFile("webui/index.html") + if err != nil { + return nil, err + } + return template.New("index").Parse(string(content)) +}) + +func (s *Server) index() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + tmpl, err := indexTemplate() + if err != nil { + JSONError(w, "ui not available", http.StatusInternalServerError) + return + } + var rendered bytes.Buffer + if err := tmpl.Execute(&rendered, struct{ AssetVersion string }{s.uiAssetVersion()}); err != nil { + JSONError(w, "ui not available", http.StatusInternalServerError) + return + } + // The shell itself carries the asset version, so it must never be + // cached: a stale shell would keep pointing at the previous release's + // asset URLs and defeat the versioning entirely. + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set(contentType, "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(rendered.Bytes()) + } +} + +func (s *Server) favicon() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + content, err := webUIAssets.ReadFile("webui/static/img/logo.png") + if err != nil { + http.NotFound(w, r) + return + } + w.Header().Set(contentType, "image/png") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(content) + } +} + +// cacheControl sets the caching policy for the embedded UI assets. Assets are +// compiled into the binary and have no file system timestamp, so the +// FileServer can offer neither Last-Modified nor a useful ETag; the freshness +// signal has to come from the URL instead. +// +// A request carrying the current asset version is immutable by construction - +// a new controller release produces a new version and therefore new URLs - so +// it may be cached indefinitely. Anything else (a bookmarked or hand-typed +// asset URL, or one left over from a previous release) must be revalidated, +// otherwise a browser could keep running a stale app.js against a newer API. +func (s *Server) cacheControl(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("v") == s.uiAssetVersion() { + w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") + } else { + w.Header().Set("Cache-Control", "no-cache") + } + w.Header().Set("X-Content-Type-Options", "nosniff") + next.ServeHTTP(w, r) + }) +} diff --git a/pkg/server/ui_test.go b/pkg/server/ui_test.go new file mode 100644 index 0000000..3f1abe6 --- /dev/null +++ b/pkg/server/ui_test.go @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (C) 2020-2025, RtBrick, Inc. +package server + +import ( + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/rtbrick/bngblaster-controller/pkg/controller" +) + +func uiServer(version string) *Server { + repository := &controller.RepositoryMock{ + ConfigFolderFunc: func() string { return configFolder }, + } + server := NewServer(repository) + server.Version = version + return server +} + +func TestServer_index_versionsEveryAssetURL(t *testing.T) { + handler := uiServer("1.2.3") + + recorder := doGet(t, handler, "/") + require.Equal(t, http.StatusOK, recorder.Code) + require.Equal(t, "text/html; charset=utf-8", recorder.Header().Get("Content-Type")) + // The shell carries the asset version, so caching it would pin the + // browser to the previous release's asset URLs. + require.Equal(t, "no-store", recorder.Header().Get("Cache-Control")) + + body := recorder.Body.String() + require.NotContains(t, body, "{{", "the template must be fully rendered") + require.Contains(t, body, "/static/js/app.js?v=1.2.3") + require.Contains(t, body, "/static/css/app.css?v=1.2.3") +} + +func TestServer_index_devBuildsGetAPerProcessVersion(t *testing.T) { + // A "dev" build has no release version to key the cache off, so assets + // must still be re-fetched after a rebuild and restart. + body := doGet(t, uiServer("dev"), "/").Body.String() + require.Contains(t, body, "/static/js/app.js?v=dev-") +} + +func TestServer_staticAssets_cachePolicyFollowsTheVersion(t *testing.T) { + handler := uiServer("1.2.3") + + // The versioned URL the shell emits is immutable by construction. + versioned := doGet(t, handler, "/static/js/app.js?v=1.2.3") + require.Equal(t, http.StatusOK, versioned.Code) + require.Equal(t, "public, max-age=31536000, immutable", versioned.Header().Get("Cache-Control")) + require.Equal(t, "nosniff", versioned.Header().Get("X-Content-Type-Options")) + + // Anything else - a bookmark, or a URL left over from an older release - + // must be revalidated so a stale app.js never runs against a newer API. + for _, target := range []string{"/static/js/app.js", "/static/js/app.js?v=0.9.0"} { + stale := doGet(t, handler, target) + require.Equal(t, http.StatusOK, stale.Code) + require.Equal(t, "no-cache", stale.Header().Get("Cache-Control"), target) + } +} + +func TestServer_uiCanBeDisabled(t *testing.T) { + repository := &controller.RepositoryMock{ + ConfigFolderFunc: func() string { return configFolder }, + } + handler := NewServer(repository, WithUI(false)) + + require.Equal(t, http.StatusNotFound, doGet(t, handler, "/").Code) + require.Equal(t, http.StatusNotFound, doGet(t, handler, "/static/js/app.js").Code) +} + +func TestServer_apiDocsAreServedIndependentlyOfTheUI(t *testing.T) { + repository := &controller.RepositoryMock{ + ConfigFolderFunc: func() string { return configFolder }, + } + handler := NewServer(repository, WithUI(false)) + + docs := doGet(t, handler, "/docs/swagger.yaml") + require.Equal(t, http.StatusOK, docs.Code) + require.Equal(t, "application/yaml", docs.Header().Get("Content-Type")) + require.True(t, strings.HasPrefix(docs.Body.String(), "openapi:"), + "expected the embedded OpenAPI document") + + require.Equal(t, http.StatusOK, doGet(t, handler, "/docs/").Code) +} diff --git a/pkg/server/webui/index.html b/pkg/server/webui/index.html new file mode 100644 index 0000000..f6d7ddf --- /dev/null +++ b/pkg/server/webui/index.html @@ -0,0 +1,517 @@ + + + + + +BNG Blaster Controller + + + + + + + + + +
+ +
+

BNG Blaster Controller

+
Test instance management
+
+ +
+ BNG Blaster Docs + API Docs + version: … +
+ +
+ +
+ + +
+

Dashboard

+ +
+
+

Test Instances

+ +
+
+ + + + + + + + + + + +
List of bngblaster test instances and their current state
NameStatusActions
+ +
+
+ +
+ + + +
+ + + +
+
+

New Test Instance

+ +
+
+ +
+ + + Letters, digits, "-" and "_" only. +
+
+ + +
+
+

Loading configuration schema…

+
+ +
+ +
+
+ + + +
+

Start Instance

+ +
+
+ +
+ Reporting & logging +
+ + +
+
+ + +
+
+ + +
+
+
+ Overrides +
+
+ + + Leave at 0 to use the value from the instance configuration. +
+
+ + +
+
+
+
+ +
+ + + +
+

Stream Detail

+ +
+
+
+
+ +
+ + + +
+

Session Detail

+ +
+
+
+
+ +
+ + + +
+

Downloads

+ +
+
+ + + + + + +
+ +
+ + + +
+

Uploads

+ +
+
+

Upload stream definitions, MRT tables, BGP update files or other auxiliary + data used by a test configuration. Files are stored inside this instance's folder.

+
+

Drag and drop a file here, or press Enter / Space to browse.

+ +
+
    +
    + +
    + + + +
    +

    Please confirm

    + +
    +
    +

    +
    + +
    + + +
    +
    +

    Log Viewer

    + + + + +
    + +
    +
    +
    +
    + + + + diff --git a/pkg/server/webui/static/css/app.css b/pkg/server/webui/static/css/app.css new file mode 100644 index 0000000..4c98c1e --- /dev/null +++ b/pkg/server/webui/static/css/app.css @@ -0,0 +1,821 @@ +/* BNG Blaster Controller — Web UI + Colors chosen for WCAG AA contrast (>= 4.5:1 for body text, >= 3:1 for large text/graphics). */ + +:root { + --color-bg: #f4f6f8; + --color-surface: #ffffff; + --color-surface-alt: #eef1f4; + --color-border: #c7ced6; + --color-text: #1a2027; + --color-text-muted: #4b5563; + --color-primary: #8a1c1c; /* RtBrick red, darkened for contrast on white */ + --color-primary-contrast: #ffffff; + --color-primary-hover: #6f1616; + --color-accent: #0b5fa5; + --color-success: #146c2e; + --color-warning: #d69429; + --color-danger: #a4222b; + --color-danger-hover: #841b23; + --color-focus: #0b5fa5; + --color-progress: #4b5563; + --radius: 6px; + --space-1: 0.25rem; + --space-2: 0.5rem; + --space-3: 0.75rem; + --space-4: 1rem; + --space-5: 1.5rem; + --space-6: 2rem; + --header-height: 3.5rem; + --logdock-height: 260px; + font-size: 16px; +} + +* { box-sizing: border-box; } + +html, body { + margin: 0; + padding: 0; + background: var(--color-bg); + color: var(--color-text); + font-family: "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + line-height: 1.45; +} + +body { padding-bottom: var(--logdock-height); } +body.logdock-collapsed { padding-bottom: 2.5rem; } + +h1, h2, h3, h4 { line-height: 1.2; margin: 0 0 var(--space-3); } +p { margin: 0 0 var(--space-3); } + +a { color: var(--color-accent); } + +/* Visible, high-contrast focus ring everywhere — never remove outline without replacing it. */ +a:focus-visible, +button:focus-visible, +input:focus-visible, +select:focus-visible, +textarea:focus-visible, +[tabindex]:focus-visible, +summary:focus-visible { + outline: 3px solid var(--color-focus); + outline-offset: 2px; +} + +.visually-hidden { + position: absolute; + width: 1px; height: 1px; + padding: 0; margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.skip-link { + position: absolute; + left: -999px; + top: 0; + background: var(--color-primary); + color: var(--color-primary-contrast); + padding: var(--space-2) var(--space-4); + z-index: 1000; + border-radius: 0 0 var(--radius) 0; +} +.skip-link:focus { + left: 0; +} + +/* ---------- Header ---------- */ +.app-header { + height: var(--header-height); + display: flex; + align-items: center; + gap: var(--space-4); + padding: 0 var(--space-4); + background: var(--color-surface); + border-bottom: 1px solid var(--color-border); + position: sticky; + top: 0; + z-index: 20; +} +.app-header .logo { height: 28px; width: auto; } +.app-header h1 { + font-size: 1.1rem; + font-weight: 600; + margin: 0; + color: var(--color-text); +} +.app-header .subtitle { + font-size: 0.8rem; + color: var(--color-text-muted); +} +.app-header .spacer { flex: 1; } +.app-header .version-badge { + font-size: 0.75rem; + color: var(--color-text-muted); + border: 1px solid var(--color-border); + padding: 0.15rem 0.5rem; + border-radius: 999px; +} +.app-header .api-docs-link { + font-size: 0.8rem; + font-weight: 600; + color: var(--color-accent); + text-decoration: none; +} +.app-header .api-docs-link:hover, +.app-header .api-docs-link:focus-visible { text-decoration: underline; } + +.app-nav { + display: flex; + gap: var(--space-2); +} +.app-nav button { + background: none; + border: 1px solid transparent; + padding: 0.4rem 0.75rem; + border-radius: var(--radius); + color: var(--color-text-muted); + font-weight: 600; + cursor: pointer; +} +.app-nav button[aria-current="page"] { + color: var(--color-primary); + border-color: var(--color-border); + background: var(--color-surface-alt); +} + +main { + max-width: 1400px; + margin: 0 auto; + padding: var(--space-5) var(--space-4); +} + +/* ---------- Buttons ---------- */ +.btn { + display: inline-flex; + align-items: center; + gap: 0.4rem; + font: inherit; + font-weight: 600; + border-radius: var(--radius); + border: 1px solid var(--color-border); + background: var(--color-surface); + color: var(--color-text); + padding: 0.5rem 0.9rem; + cursor: pointer; + line-height: 1.1; + text-decoration: none; +} +.btn:hover { background: var(--color-surface-alt); } +.btn:disabled { opacity: 0.55; cursor: not-allowed; } +.btn-primary { + background: var(--color-primary); + border-color: var(--color-primary); + color: var(--color-primary-contrast); +} +.btn-primary:hover { background: var(--color-primary-hover); border-color: var(--color-primary-hover); } +.btn-danger { + background: var(--color-danger); + border-color: var(--color-danger); + color: #fff; +} +.btn-danger:hover { background: var(--color-danger-hover); } +.btn-sm { padding: 0.3rem 0.6rem; font-size: 0.85rem; } +.btn-icon { padding: 0.35rem; } + +/* ---------- Cards / sections ---------- */ +.card { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius); + padding: var(--space-4); + margin-bottom: var(--space-5); +} +.card-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); + margin-bottom: var(--space-4); + flex-wrap: wrap; +} +.card-header h2 { margin: 0; font-size: 1.1rem; } + +/* ---------- Tables ---------- */ +table { width: 100%; border-collapse: collapse; } +caption { text-align: left; font-weight: 600; margin-bottom: var(--space-2); } +th, td { + text-align: left; + padding: 0.55rem 0.6rem; + border-bottom: 1px solid var(--color-border); + font-size: 0.92rem; +} +th { color: var(--color-text-muted); font-weight: 600; white-space: nowrap; } +tbody tr:hover { background: var(--color-surface-alt); } +.table-scroll { overflow-x: auto; } +.actions-cell { display: flex; gap: var(--space-2); flex-wrap: wrap; } + +.status-pill { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.15rem 0.55rem; + border-radius: 999px; + font-size: 0.8rem; + font-weight: 700; + border: 1px solid transparent; +} +.status-pill.started { background: #e4f4e8; color: var(--color-success); border-color: #bfe4c9; } +.status-pill.stopped { background: #f0f0f0; color: var(--color-text-muted); border-color: var(--color-border); } +.status-pill.duration-pill { background: var(--color-surface-alt); color: var(--color-text-muted); border-color: var(--color-border); font-weight: 600; } +.status-pill.duration-pill::before { content: none; } +.status-pill::before { + content: ""; + width: 0.5rem; height: 0.5rem; + border-radius: 50%; + background: currentColor; +} + +.empty-state { + text-align: center; + color: var(--color-text-muted); + padding: var(--space-6) var(--space-4); +} + +/* ---------- Forms ---------- */ +.field { + margin-bottom: var(--space-3); +} +.field > label, .field > legend { + display: block; + font-weight: 600; + margin-bottom: 0.25rem; + font-size: 0.9rem; +} +.field .hint { + display: block; + color: var(--color-text-muted); + font-size: 0.8rem; + margin-top: 0.2rem; +} +input[type="text"], input[type="number"], input[type="search"], select, textarea { + font: inherit; + width: 100%; + padding: 0.5rem 0.6rem; + border: 1px solid var(--color-border); + border-radius: var(--radius); + background: var(--color-surface); + color: var(--color-text); +} +textarea { min-height: 5rem; font-family: ui-monospace, Consolas, monospace; } +fieldset { + border: 1px solid var(--color-border); + border-radius: var(--radius); + padding: var(--space-3) var(--space-4) var(--space-4); + margin: 0 0 var(--space-4); +} +fieldset fieldset { background: var(--color-surface-alt); } +.checkbox-field { display: flex; align-items: center; gap: 0.5rem; } +.checkbox-field input { width: auto; } +.required-mark { color: var(--color-danger); margin-left: 0.15rem; } +.form-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: var(--space-3) var(--space-4); +} +/* Structural sub-fields (nested objects rendered as
    , list/array + fields such as network/access/a10nsp/links/lag interfaces rendered as +
    ) always take the full row width and stack below the simple + scalar fields, instead of being squeezed into one ~220px grid column — + they typically hold many nested fields of their own and need the room. */ +.form-grid > details, +.form-grid > fieldset { + grid-column: 1 / -1; +} +.array-item { + display: flex; + gap: var(--space-2); + align-items: flex-end; + margin-bottom: var(--space-2); +} +.array-item > div { flex: 1; } +.form-error { + color: var(--color-danger); + font-size: 0.85rem; + margin-top: 0.25rem; +} +.form-status[role="alert"] { + padding: var(--space-2) var(--space-3); + border-radius: var(--radius); + margin-bottom: var(--space-3); +} +.form-status.error { background: #fbe7e8; color: var(--color-danger); border: 1px solid #f0c1c4; } +.form-status.success { background: #e4f4e8; color: var(--color-success); border: 1px solid #bfe4c9; } +.form-status-title { font-weight: 700; margin-bottom: 0.35rem; } +.form-status-detail { + margin: 0; + font-family: ui-monospace, Consolas, monospace; + font-size: 0.85rem; + white-space: pre-wrap; + word-break: break-word; +} + +/* ---------- Dialogs ---------- */ +dialog { + border: none; + border-radius: var(--radius); + padding: 0; + max-width: min(720px, 92vw); + width: 100%; + max-height: 88vh; + color: var(--color-text); + box-shadow: 0 10px 40px rgba(0,0,0,0.3); + /* Closed elements are display:none by default (UA stylesheet); + author rules always win over the UA stylesheet regardless of + selector specificity, so this MUST stay display:none here and only + switch to flex once [open] is set below — otherwise every dialog + renders inline in the page flow all the time instead of as a modal. */ + display: none; + flex-direction: column; +} +dialog[open] { display: flex; } +/* The New Instance dialog always uses (near) the full viewport — it hosts + the schema-driven form and the raw JSON editor, both of which need room. + Every other dialog (confirm, stream detail, start instance, ...) keeps + its compact, content-sized default above. */ +dialog.dialog-wide { + width: 98vw; + height: 94vh; + max-width: 98vw; + max-height: 94vh; +} +dialog::backdrop { background: rgba(15, 18, 22, 0.55); } +dialog > form[method="dialog"] { + display: flex; + flex-direction: column; + min-height: 0; + height: 100%; +} +.dialog-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-4); + border-bottom: 1px solid var(--color-border); + flex: 0 0 auto; +} +.dialog-header h2 { font-size: 1.1rem; margin: 0; } +.dialog-body { padding: var(--space-4); overflow-y: auto; flex: 1 1 auto; min-height: 0; } +.dialog-footer { + display: flex; + justify-content: flex-end; + gap: var(--space-2); + padding: var(--space-4); + border-top: 1px solid var(--color-border); + flex: 0 0 auto; +} + +/* ---------- Drop zone ---------- */ +.dropzone { + border: 2px dashed var(--color-border); + border-radius: var(--radius); + padding: var(--space-5); + text-align: center; + color: var(--color-text-muted); + background: var(--color-surface-alt); +} +.dropzone.dragover { + border-color: var(--color-accent); + color: var(--color-accent); + background: #eaf3fb; +} +.upload-list { margin-top: var(--space-3); font-size: 0.88rem; } +.upload-list li { display: flex; justify-content: space-between; gap: var(--space-2); padding: 0.2rem 0; } + +/* ---------- Config input mode toggle (Form / JSON) ---------- */ +.mode-toggle { + display: inline-flex; + gap: var(--space-1); + margin-bottom: var(--space-3); + background: var(--color-surface-alt); + border: 1px solid var(--color-border); + border-radius: var(--radius); + padding: 2px; +} +.mode-toggle button { + background: none; + border: none; + border-radius: calc(var(--radius) - 2px); + padding: 0.35rem 0.8rem; + font-weight: 600; + color: var(--color-text-muted); + cursor: pointer; +} +.mode-toggle button[aria-pressed="true"] { + background: var(--color-surface); + color: var(--color-primary); + box-shadow: 0 1px 2px rgba(0,0,0,0.15); +} +/* ---------- Schema-aware JSON editor ---------- */ +.json-editor { position: relative; } +#schema-json-textarea, +.json-editor-highlight { + margin: 0; + padding: 0.5rem 0.6rem; + border: 1px solid transparent; + border-radius: var(--radius); + font-family: ui-monospace, Consolas, monospace; + font-size: 0.85rem; + line-height: 1.5; + white-space: pre-wrap; + overflow-wrap: break-word; + tab-size: 2; +} +#schema-json-textarea { + position: relative; + z-index: 1; + width: 100%; + min-height: 50vh; + background: transparent; + color: transparent; + caret-color: var(--color-text); + border-color: var(--color-border); + resize: vertical; +} +.json-editor-highlight { + position: absolute; + inset: 0; + z-index: 0; + overflow: auto; + pointer-events: none; + background: var(--color-surface); + color: var(--color-text); +} +.json-editor-highlight code { white-space: inherit; font: inherit; } +.jt-key { color: var(--color-accent); font-weight: 600; } +.jt-string { color: var(--color-success); } +.jt-number, .jt-boolean, .jt-null { color: #7a4a00; } +.jt-punct { color: var(--color-text-muted); } +.jt-error { text-decoration: underline wavy var(--color-danger); text-underline-offset: 2px; } + +.json-suggest { + position: absolute; + z-index: 5; + pointer-events: none; + list-style: none; + margin: 0; + padding: 0.25rem 0; + min-width: 220px; + max-width: min(420px, 90vw); + max-height: 220px; + overflow-y: auto; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius); + box-shadow: 0 6px 20px rgba(0,0,0,0.2); +} +.json-suggest li { + padding: 0.3rem 0.6rem; + font-size: 0.85rem; + display: flex; + gap: 0.5rem; + align-items: baseline; +} +.json-suggest li .suggest-name { font-family: ui-monospace, Consolas, monospace; font-weight: 600; white-space: nowrap; } +.json-suggest li .suggest-desc { color: var(--color-text-muted); font-size: 0.78rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +.json-editor-info { + margin-top: var(--space-2); + padding: 0.4rem 0.6rem; + border-radius: var(--radius); + background: var(--color-surface-alt); + font-size: 0.82rem; + color: var(--color-text-muted); + min-height: 1.6em; +} +.json-editor-info strong { color: var(--color-text); font-family: ui-monospace, Consolas, monospace; } +.json-info-type { font-style: italic; } + +.json-problems { + list-style: none; + margin: var(--space-2) 0 0; + padding: 0; + max-height: 140px; + overflow-y: auto; +} +.json-problems:empty { display: none; margin: 0; } +.json-problems li { + padding: 0.25rem 0.5rem; + font-size: 0.82rem; + color: var(--color-danger); + cursor: pointer; + border-radius: var(--radius); +} +.json-problems li:hover { background: #fbe7e8; } +.json-problems li .problem-loc { color: var(--color-text-muted); margin-right: 0.4rem; font-family: ui-monospace, Consolas, monospace; } + +/* ---------- Tabs ---------- */ +[role="tablist"] { + display: flex; + gap: var(--space-2); + border-bottom: 1px solid var(--color-border); + margin-bottom: var(--space-4); + flex-wrap: wrap; +} +[role="tab"] { + background: none; + border: none; + border-bottom: 3px solid transparent; + padding: 0.6rem 0.2rem; + margin-right: var(--space-4); + font-weight: 600; + color: var(--color-text-muted); + cursor: pointer; +} +[role="tab"][aria-selected="true"] { + color: var(--color-primary); + border-bottom-color: var(--color-primary); +} +/* A tab that has nothing to show - Sessions during a pure stream test, or + Streams during a run with no traffic flows - is hidden outright. Stated + explicitly (rather than relying on the UA [hidden] rule) because the tab + strip is a flex container and the tabs carry author styling. */ +[role="tab"][hidden] { display: none; } +[role="tabpanel"] { outline: none; } +[role="tabpanel"][hidden] { display: none; } + +/* ---------- Progress bars (session overview) ---------- */ +.meter-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: var(--space-4); + margin-bottom: var(--space-4); +} +.stat-tile { + background: var(--color-surface-alt); + border: 1px solid var(--color-border); + border-radius: var(--radius); + padding: var(--space-3); +} +.stat-tile .stat-value { font-size: 1.6rem; font-weight: 700; } +.stat-tile .stat-label { font-size: 0.8rem; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.03em; } + +.session-meter { margin-bottom: var(--space-3); } +.session-meter .meter-label { + display: flex; + justify-content: space-between; + font-size: 0.85rem; + margin-bottom: 0.25rem; +} +.session-meter .meter-label .count { color: var(--color-text-muted); font-variant-numeric: tabular-nums; } +.session-meter progress { + width: 100%; + height: 1.1rem; + appearance: none; + border: 1px solid var(--color-border); + border-radius: 999px; + overflow: hidden; + background: var(--color-surface-alt); +} +.session-meter progress::-webkit-progress-bar { background: var(--color-surface-alt); } +.session-meter progress::-webkit-progress-value { background: var(--color-progress); } +.session-meter progress::-moz-progress-bar { background: var(--color-progress); } +.session-meter.is-complete progress::-webkit-progress-value { background: var(--color-success); } +.session-meter.is-complete progress::-moz-progress-bar { background: var(--color-success); } + +/* Sessions outstanding: reaching 100% means every session is stuck + outstanding, so that state is flagged red instead of green. */ +.session-meter--outstanding.is-complete progress::-webkit-progress-value { background: var(--color-progress); } +.session-meter--outstanding.is-complete progress::-moz-progress-bar { background: var(--color-progress); } + +/* Sessions terminated: always red (the same red used for the Kill/Delete + buttons), regardless of percentage. */ +.session-meter--terminated progress::-webkit-progress-value { background: var(--color-danger); } +.session-meter--terminated progress::-moz-progress-bar { background: var(--color-danger); } + +/* ---------- Interface statistics (Session Overview) ---------- */ +.iface-group { margin-top: var(--space-4); } +.iface-group h3 { margin-bottom: var(--space-2); } +.iface-card-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); + gap: var(--space-4); + margin-bottom: var(--space-2); +} +.iface-card { background: var(--color-surface-alt); border: 1px solid var(--color-border); border-radius: var(--radius); padding: var(--space-3); } +.iface-card h4 { margin: 0 0 var(--space-2); font-size: 0.95rem; } +.iface-stat-table { width: 100%; border-collapse: collapse; font-size: 0.85rem; } +.iface-stat-table thead th { color: var(--color-text-muted); font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.03em; text-align: left; padding-bottom: 0.3rem; } +.iface-stat-table th[scope="row"] { text-align: left; font-weight: 500; color: var(--color-text-muted); padding: 0.2rem 0.4rem 0.2rem 0; white-space: nowrap; } +.iface-stat-table td { padding: 0.2rem 0.4rem; font-variant-numeric: tabular-nums; } + +/* ---------- Virtual scroll stream table ---------- */ +.vscroll-viewport { + height: 420px; + overflow-y: auto; + border: 1px solid var(--color-border); + border-radius: var(--radius); + position: relative; +} +.vscroll-spacer { width: 100%; } +.vscroll-row-table { width: 100%; border-collapse: collapse; table-layout: fixed; } +.vscroll-row-table th { position: sticky; top: 0; background: var(--color-surface); z-index: 1; } +.stream-row-loading td { color: var(--color-text-muted); font-style: italic; } +.rx-loss-nonzero { color: var(--color-danger); font-weight: 700; } +/* Start-or-Stop + Detail always stay on one row (the table uses a fixed row + height for virtual scrolling, so wrapping would break it). overflow-x + is deliberately left at its default (visible) rather than auto: a + scrollable overflow reserves scrollbar space that renders as a stray + line under this column alone, out of line with the row border under + every other column. + This flex layout lives on a div *inside* the , not the itself - + a table cell with display:flex forces the browser to reconcile flex + sizing with table row-height layout, which is exactly the kind of thing + that produces a 1px rounding gap between this column's row border and + every other column's, splitting one border line into two. + Shared by the stream and session virtual-scroll tables. */ +.row-actions-cell { + display: flex; + flex-wrap: nowrap; + gap: var(--space-1); +} +.row-actions-cell .btn { padding: 0.2rem 0.35rem; font-size: 0.78rem; white-space: nowrap; } + +/* ---------- Badges (shared pill style: command output meta, stream flags) ---------- */ +.badge { + display: inline-block; + padding: 0.1rem 0.55rem; + border-radius: 999px; + border: 1px solid var(--color-border); + font-weight: 600; +} +.badge.status-ok, .badge.badge-yes { background: #e4f4e8; color: var(--color-success); border-color: #bfe4c9; } +.badge.status-error, .badge.badge-no { background: #fbe4e4; color: var(--color-danger); border-color: #f0bcbc; } +.badge.status-code { background: var(--color-surface-alt); color: var(--color-text-muted); } + +/* ---------- Command builder ---------- */ +.command-output-meta { + display: flex; + gap: var(--space-2); + align-items: center; + margin-bottom: var(--space-2); + font-size: 0.85rem; +} +/* nowrap (not wrap) so a row with both badges is never taller than a row + with zero or one - the virtual-scroll spacer math above assumes every + row is exactly rowHeight tall, and a row that wraps to a second line + breaks that assumption, which is what made the scrollbar wobble once + scrolled to the end. */ +.stream-flags-cell { display: flex; gap: var(--space-1); flex-wrap: nowrap; } +.stream-flags-cell .badge { font-size: 0.72rem; padding: 0.05rem 0.35rem; white-space: nowrap; } +.command-output { + background: #0f1115; + color: #d8dee6; + padding: var(--space-3); + border-radius: var(--radius); + font-family: ui-monospace, Consolas, monospace; + font-size: 0.85rem; + overflow: auto; + max-height: 340px; + white-space: pre-wrap; + word-break: break-word; +} + +/* ---------- Detail dialogs (stream-info / session-info field lists) ---------- */ +/* One key/value pair per row: a label column sized to its content and a + value column taking the rest. Deliberately not .form-grid - that grid is + auto-fit/minmax for wrapping *complete* label+input fields, and applying + it here let dt/dd flow independently into it, packing two unrelated + key/value pairs (four grid cells) onto one row. */ +.detail-grid { + display: grid; + grid-template-columns: max-content 1fr; + gap: var(--space-2) var(--space-4); + align-items: baseline; +} +.detail-grid dt { font-weight: 600; color: var(--color-text-muted); white-space: nowrap; } +.detail-grid dd { margin: 0; overflow-wrap: anywhere; } +.detail-value-wide { grid-column: 1 / -1; } +.detail-value-json { + margin: 0.25rem 0 0; + padding: var(--space-2) var(--space-3); + background: #0f1115; + color: #d8dee6; + border-radius: var(--radius); + font-family: ui-monospace, Consolas, monospace; + font-size: 0.85rem; + overflow: auto; + max-height: 240px; + white-space: pre-wrap; + word-break: break-word; +} + +/* ---------- Log dock ---------- */ +.logdock { + position: fixed; + left: 0; right: 0; bottom: 0; + height: var(--logdock-height); + background: #11151a; + color: #d8dee6; + border-top: 2px solid var(--color-border); + display: flex; + flex-direction: column; + z-index: 30; +} +.logdock-collapsed .logdock { height: 2.5rem; } +.logdock-header { + display: flex; + align-items: center; + gap: var(--space-3); + padding: 0.4rem var(--space-4); + border-bottom: 1px solid #2a303a; + flex-shrink: 0; +} +.logdock-header h2 { font-size: 0.85rem; margin: 0; color: #d8dee6; text-transform: uppercase; letter-spacing: 0.04em; } +.logdock-header .spacer { flex: 1; } +.logdock-header .btn { background: #1c2128; color: #d8dee6; border-color: #2a303a; } +.logdock-header .btn:hover { background: #262c35; } +.logdock-body { + flex: 1; + overflow-y: auto; + padding: var(--space-2) var(--space-4); + font-family: ui-monospace, Consolas, monospace; + font-size: 0.82rem; +} +.logdock-collapsed .logdock-body, +.logdock-collapsed .logdock-footer { display: none; } +.log-line { white-space: pre-wrap; word-break: break-word; border-bottom: 1px solid rgba(255,255,255,0.04); padding: 0.1rem 0; } +.log-line.level-error, .log-line.level-err { color: #ff8a8a; } +.log-line.level-warn, .log-line.level-warning { color: #ffcf7a; } + +.sr-status { position: absolute; width: 1px; height: 1px; overflow: hidden; } + +@media (max-width: 720px) { + .app-header h1 { font-size: 0.95rem; } + .app-header .subtitle { display: none; } + :root { --logdock-height: 200px; } +} + +/* ===================== TOASTS ===================== */ +/* Visible counterpart of the #global-status screen-reader live region, so + that failures reported through announce() are not invisible to sighted + users. Sits above the log dock and below any open modal dialog. */ +.toast-region { + position: fixed; + top: var(--space-3); + right: var(--space-3); + z-index: 60; + display: flex; + flex-direction: column; + gap: var(--space-2); + width: min(24rem, calc(100vw - 2 * var(--space-3))); + pointer-events: none; +} + +.toast { + pointer-events: auto; + display: flex; + align-items: flex-start; + gap: var(--space-2); + padding: var(--space-2) var(--space-3); + border-radius: var(--radius); + border: 1px solid var(--color-border); + border-left-width: 4px; + background: var(--color-surface); + box-shadow: 0 6px 20px rgb(0 0 0 / 18%); + font-size: 0.875rem; + line-height: 1.4; + animation: toast-in 120ms ease-out; +} + +.toast.error { border-left-color: var(--color-danger); } +.toast.success { border-left-color: var(--color-success); } +.toast.info { border-left-color: var(--color-primary); } + +.toast-message { + flex: 1; + overflow-wrap: anywhere; +} + +.toast-dismiss { + flex: none; + border: 0; + background: none; + cursor: pointer; + padding: 0 0.15rem; + font-size: 1rem; + line-height: 1; + color: var(--color-text-muted); +} + +.toast-dismiss:hover { color: var(--color-text); } + +@keyframes toast-in { + from { opacity: 0; transform: translateY(-0.35rem); } + to { opacity: 1; transform: none; } +} + +@media (prefers-reduced-motion: reduce) { + .toast { animation: none; } +} diff --git a/pkg/server/webui/static/img/logo.png b/pkg/server/webui/static/img/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..fcb016eceede054caf06c5c35bce7e983d20003e GIT binary patch literal 76793 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 z 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 literal 0 HcmV?d00001 diff --git a/pkg/server/webui/static/js/app.js b/pkg/server/webui/static/js/app.js new file mode 100644 index 0000000..2d4b197 --- /dev/null +++ b/pkg/server/webui/static/js/app.js @@ -0,0 +1,2940 @@ +// BNG Blaster Controller — Web UI application logic. +// Vanilla JS, no build step, no framework: this file is served as-is by the +// embedded controller binary. +(function () { + 'use strict'; + + //= ======================================================================== + // API client + //= ======================================================================== + const API = { + async fetchJSON(url, opts) { + const res = await fetch(url, opts); + if (res.status === 204 || res.status === 202) return null; + const text = await res.text(); + let body = null; + if (text) { + try { body = JSON.parse(text); } catch (e) { body = text; } + } + if (!res.ok) { + const msg = (body && body.message) ? body.message : (typeof body === 'string' ? body : res.statusText); + const err = new Error(msg || ('HTTP ' + res.status)); + err.status = res.status; + err.body = body; + throw err; + } + return body; + }, + version() { return this.fetchJSON('/api/v1/version'); }, + schema() { return this.fetchJSON('/api/v1/schema'); }, + interfaces() { return this.fetchJSON('/api/v1/interfaces'); }, + // detail=true returns [{name, status}] in one request instead of the + // plain name array plus one status request per instance. + instances(detail) { + return this.fetchJSON('/api/v1/instances' + (detail ? '?detail=true' : '')); + }, + status(name) { return this.fetchJSON('/api/v1/instances/' + encodeURIComponent(name)); }, + create(name, config) { + return this.fetchJSON('/api/v1/instances/' + encodeURIComponent(name), { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(config), + }); + }, + getConfig(name) { + // config.json is served as a plain file (also used for the download + // link), so it doesn't follow the {status,message} JSON error contract + // used elsewhere and can't go through fetchJSON as-is. + return fetch('/api/v1/instances/' + encodeURIComponent(name) + '/config.json').then((res) => { + if (!res.ok) throw new Error('HTTP ' + res.status); + return res.json(); + }); + }, + delete(name) { + return this.fetchJSON('/api/v1/instances/' + encodeURIComponent(name), { method: 'DELETE' }); + }, + start(name, runningConfig) { + return this.fetchJSON('/api/v1/instances/' + encodeURIComponent(name) + '/_start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(runningConfig), + }); + }, + stop(name) { + return this.fetchJSON('/api/v1/instances/' + encodeURIComponent(name) + '/_stop', { method: 'POST' }); + }, + kill(name) { + return this.fetchJSON('/api/v1/instances/' + encodeURIComponent(name) + '/_kill', { method: 'POST' }); + }, + command(name, command, args) { + return this.fetchJSON('/api/v1/instances/' + encodeURIComponent(name) + '/_command', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ command: command, arguments: args || {} }), + }); + }, + streams(name, offset, limit, filters) { + const params = new URLSearchParams({ offset: String(offset), limit: String(limit) }); + Object.entries(filters || {}).forEach(([k, v]) => { + if (v !== undefined && v !== null && v !== '') params.set(k, v); + }); + return this.fetchJSON('/api/v1/instances/' + encodeURIComponent(name) + '/_streams?' + params.toString()); + }, + sessions(name, offset, limit, filters) { + const params = new URLSearchParams({ offset: String(offset), limit: String(limit) }); + Object.entries(filters || {}).forEach(([k, v]) => { + if (v !== undefined && v !== null && v !== '') params.set(k, v); + }); + return this.fetchJSON('/api/v1/instances/' + encodeURIComponent(name) + '/_sessions?' + params.toString()); + }, + // Aggregates session-counters, the three interface commands and + // test-info into one cached response, instead of five separate + // control-socket round-trips per poll per open browser tab. + overview(name) { + return this.fetchJSON('/api/v1/instances/' + encodeURIComponent(name) + '/_overview'); + }, + logs(name, offset, limit) { + return this.fetchJSON('/api/v1/instances/' + encodeURIComponent(name) + '/_logs?offset=' + offset + (limit ? '&limit=' + limit : '')); + }, + files(name) { + return this.fetchJSON('/api/v1/instances/' + encodeURIComponent(name) + '/_files'); + }, + fileDownloadURL(name, file) { + return '/api/v1/instances/' + encodeURIComponent(name) + '/_files/' + encodeURIComponent(file); + }, + upload(name, file) { + const fd = new FormData(); + fd.append('file', file); + return fetch('/api/v1/instances/' + encodeURIComponent(name) + '/_upload', { method: 'POST', body: fd }) + .then((res) => { + if (!res.ok) return res.text().then((t) => { throw new Error(t || res.statusText); }); + }); + }, + }; + + //= ======================================================================== + // Small DOM / a11y helpers + //= ======================================================================== + const $ = (sel, root) => (root || document).querySelector(sel); + const $all = (sel, root) => Array.from((root || document).querySelectorAll(sel)); + const el = (tag, attrs, children) => { + const node = document.createElement(tag); + Object.entries(attrs || {}).forEach(([k, v]) => { + if (v === undefined || v === null) return; + if (k === 'class') node.className = v; + else if (k === 'text') node.textContent = v; + else if (k.startsWith('on') && typeof v === 'function') node.addEventListener(k.slice(2), v); + else node.setAttribute(k, v); + }); + (children || []).forEach((c) => { if (c) node.appendChild(c); }); + return node; + }; + + let idCounter = 0; + const nextId = (prefix) => prefix + '-' + (++idCounter); + + // The "instance is not running" paths overwrite these placeholders in + // place, so the original wording is captured once up front to be able to + // restore it when the view is reset for another instance. + const DEFAULT_EMPTY_TEXT = {}; + + //= ======================================================================== + // Polling + //= ======================================================================== + // Every recurring refresh in this UI goes through schedulePoll, which skips + // ticks while the browser tab is in the background. Without that, a + // forgotten tab keeps hammering the controller's unix control socket + // indefinitely - and the data it fetches is not being looked at anyway. + // Becoming visible again runs each active poll immediately, so the view is + // current by the time the user has finished switching to it rather than up + // to one interval stale. + const activePolls = new Set(); + + function schedulePoll(fn, intervalMs) { + const poll = { + fn: fn, + id: setInterval(() => { if (!document.hidden) fn(); }, intervalMs), + }; + activePolls.add(poll); + return poll; + } + + function cancelPoll(poll) { + if (!poll) return null; + clearInterval(poll.id); + activePolls.delete(poll); + return null; + } + + document.addEventListener('visibilitychange', () => { + if (document.hidden) return; + activePolls.forEach((poll) => poll.fn()); + }); + + const TOAST_TIMEOUT_MS = { error: 10000, success: 4000, info: 6000 }; + + // Shows a message as a visible toast. #global-status alone is a + // screen-reader-only live region, so anything announced through it used to + // be completely invisible to sighted users - which meant a failed stop, + // kill, delete or command reported nothing at all on screen. + function toast(message, kind) { + const region = $('#toast-region'); + if (!region) return; + const node = el('div', { class: 'toast ' + (kind || 'info') }, [ + el('div', { class: 'toast-message', text: message }), + ]); + const dismiss = () => { + if (node.parentNode) node.parentNode.removeChild(node); + clearTimeout(timer); + }; + node.appendChild(el('button', { + type: 'button', class: 'toast-dismiss', 'aria-label': 'Dismiss notification', + text: '\u2715', onclick: dismiss, + })); + region.appendChild(node); + // Errors linger noticeably longer: they usually carry something the user + // needs to read and act on, rather than a confirmation they can ignore. + const timer = setTimeout(dismiss, TOAST_TIMEOUT_MS[kind] || TOAST_TIMEOUT_MS.info); + // Never let the stack grow without bound during a burst of failures. + while (region.childElementCount > 5) region.removeChild(region.firstChild); + } + + // Announces a message to assistive technology *and* shows it on screen. + // kind is 'error' | 'success' | 'info' (default 'info'). + function announce(message, kind) { + const region = $('#global-status'); + region.textContent = ''; + // Force screen readers to re-announce even if the text is identical. + window.requestAnimationFrame(() => { region.textContent = message; }); + toast(message, kind); + } + + // Runs an action that talks to the controller, reporting both outcomes. + // Without this every caller had to remember its own try/catch; the ones + // that forgot (stop, kill) turned a failure into a silent unhandled + // promise rejection and left the UI showing the wrong state. + async function withFeedback(action, successMessage, failureMessage) { + try { + const result = await action(); + if (successMessage) announce(successMessage, 'success'); + return result; + } catch (e) { + announce(failureMessage + ': ' + e.message, 'error'); + return undefined; + } + } + + // Sets (or clears) a dialog's inline status/alert box with consistent + // error/success styling. Pass an empty message to clear it back to an + // invisible, unstyled state. + function setStatusMessage(el, message, kind) { + el.textContent = message || ''; + el.className = message ? ('form-status' + (kind ? ' ' + kind : '')) : ''; + } + + // A start/save failure can be the raw (possibly multi-line) stderr output + // of the bngblaster process itself - e.g. a JSON config validation error. + // That deserves more than a line of plain text quietly sitting in a small + // alert box: a clear heading plus a monospace, line-break-preserving + // block makes it obvious something failed and keeps the actual reason + // readable instead of being squashed onto one line. + function showDetailedError(box, title, message) { + box.innerHTML = ''; + box.className = 'form-status error'; + box.appendChild(el('div', { class: 'form-status-title', text: title })); + box.appendChild(el('pre', { class: 'form-status-detail', text: message })); + } + + function escapeHtml(str) { + return String(str).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); + } + + //= ======================================================================== + // Dialog handling (native , with focus restore) + //= ======================================================================== + let lastFocusedBeforeDialog = null; + function openDialog(id) { + const dialog = document.getElementById(id); + lastFocusedBeforeDialog = document.activeElement; + dialog.showModal(); + const focusable = dialog.querySelector('input, select, textarea, button'); + if (focusable) focusable.focus(); + } + function closeDialog(id) { + const dialog = document.getElementById(id); + if (dialog.open) dialog.close(); + if (lastFocusedBeforeDialog && document.contains(lastFocusedBeforeDialog)) { + lastFocusedBeforeDialog.focus(); + } + } + document.addEventListener('click', (ev) => { + const trigger = ev.target.closest('[data-close-dialog]'); + if (trigger) closeDialog(trigger.getAttribute('data-close-dialog')); + }); + + function confirmAction(message) { + return new Promise((resolve) => { + $('#dialog-confirm-message').textContent = message; + openDialog('dialog-confirm'); + const dialog = $('#dialog-confirm'); + let confirmed = false; + const onConfirm = () => { confirmed = true; closeDialog('dialog-confirm'); }; + const onClose = () => { + dialog.removeEventListener('close', onClose); + $('#btn-confirm-ok').removeEventListener('click', onConfirm); + resolve(confirmed); + }; + $('#btn-confirm-ok').addEventListener('click', onConfirm); + dialog.addEventListener('close', onClose); + }); + } + + //= ======================================================================== + // Application state + //= ======================================================================== + const state = { + instances: [], + interfaces: [], + schema: null, + currentInstance: null, + instanceCommands: {}, // name -> normalized command list + // Drives the whole instance detail view: header badge on every tab, plus + // the Session Overview sections while that tab is visible. + overviewTimer: null, + stream: { instance: null, total: 0, rowHeight: 34, buffer: 8, pending: false, timer: null, pollTimer: null, filters: {}, detailFlowId: null, detailTimer: null }, + session: { instance: null, total: 0, rowHeight: 34, buffer: 8, pending: false, timer: null, pollTimer: null, filters: {}, detailSessionId: null, detailTimer: null }, + // generations maps instance -> the identity of the run.log it last read, + // so a restarted instance (which recreates the file) is detected. + log: { instance: null, offsets: {}, generations: {}, paused: false, manualSelect: false, timer: null }, + downloads: { instance: null }, + uploads: { instance: null }, + }; + + //= ======================================================================== + // Dashboard + //= ======================================================================== + async function refreshVersion() { + try { + const v = await API.version(); + $('#version-badge').textContent = 'controller ' + v['bngblasterctrl-version'] + ' · bngblaster ' + v['bngblaster-version']; + } catch (e) { /* non-fatal */ } + } + + async function loadInstances() { + let instances = []; + try { + instances = await API.instances(true) || []; + } catch (e) { + announce('Failed to load instances: ' + e.message, 'error'); + return; + } + state.instances = instances.map((i) => ({ name: i.name, status: i.status })); + renderInstancesTable(); + populateInstanceSelects(); + } + + function renderInstancesTable() { + const tbody = $('#instances-tbody'); + tbody.innerHTML = ''; + $('#instances-empty').hidden = state.instances.length > 0; + state.instances.forEach((inst) => { + const running = inst.status === 'started'; + const actions = el('td', { class: 'actions-cell' }, [ + el('button', { + class: 'btn btn-sm', type: 'button', text: 'Open', + onclick: () => openInstance(inst.name), + }), + running + ? el('button', { class: 'btn btn-sm', type: 'button', text: 'Stop', onclick: () => doStop(inst.name) }) + : el('button', { class: 'btn btn-sm btn-primary', type: 'button', text: 'Start', onclick: () => openStartDialog(inst.name) }), + running ? el('button', { class: 'btn btn-sm btn-danger', type: 'button', text: 'Kill', onclick: () => doKill(inst.name) }) : null, + !running ? el('button', { class: 'btn btn-sm', type: 'button', text: 'Edit', onclick: () => openInstanceDialog(inst.name) }) : null, + !running ? el('button', { class: 'btn btn-sm', type: 'button', text: 'Download', onclick: () => showDownloads(inst.name) }) : null, + !running ? el('button', { class: 'btn btn-sm', type: 'button', text: 'Upload', onclick: () => showUploads(inst.name) }) : null, + !running ? el('button', { class: 'btn btn-sm btn-danger', type: 'button', text: 'Delete', onclick: () => doDelete(inst.name) }) : null, + ]); + const row = el('tr', {}, [ + el('th', { scope: 'row', text: inst.name }), + el('td', {}, [el('span', { class: 'status-pill ' + (running ? 'started' : 'stopped'), text: running ? 'started' : 'stopped' })]), + actions, + ]); + tbody.appendChild(row); + }); + } + + function formatFileSize(bytes) { + if (!Number.isFinite(bytes)) return ''; + const units = ['B', 'KB', 'MB', 'GB']; + let size = bytes; + let unit = 0; + while (size >= 1024 && unit < units.length - 1) { size /= 1024; unit++; } + return (unit === 0 ? String(size) : size.toFixed(size < 10 ? 2 : 1)) + ' ' + units[unit]; + } + + // Downloads dialog: lists the files present in an instance's result + // folder (name, size, download button), mirroring the stream/session + // detail dialogs instead of opening a separate browser tab/window. + async function loadDownloads(showLoading) { + const name = state.downloads.instance; + if (!name) return; + const empty = $('#downloads-empty'); + const error = $('#downloads-error'); + const table = $('#downloads-table'); + if (showLoading) { + empty.hidden = true; + error.hidden = true; + table.hidden = true; + } + try { + const files = await API.files(name); + error.hidden = true; + if (!files || files.length === 0) { + table.hidden = true; + empty.hidden = false; + return; + } + empty.hidden = true; + const tbody = $('#downloads-tbody'); + tbody.innerHTML = ''; + files.forEach((f) => { + tbody.appendChild(el('tr', {}, [ + el('td', { text: f.name }), + el('td', { text: formatFileSize(f.size) }), + el('td', {}, [el('a', { + class: 'btn btn-sm', href: API.fileDownloadURL(name, f.name), download: f.name, text: 'Download', + })]), + ])); + }); + table.hidden = false; + } catch (e) { + table.hidden = true; + empty.hidden = true; + error.hidden = false; + error.textContent = 'Failed to load files: ' + e.message; + } + } + + function showDownloads(name) { + state.downloads.instance = name; + $('#dialog-downloads-title').textContent = 'Downloads — ' + name; + openDialog('dialog-downloads'); + loadDownloads(true); + } + + function showUploads(name) { + state.uploads.instance = name; + $('#dialog-uploads-title').textContent = 'Uploads — ' + name; + $('#upload-list').innerHTML = ''; + openDialog('dialog-uploads'); + } + + $('#btn-downloads-refresh').addEventListener('click', () => loadDownloads(true)); + + function populateInstanceSelects() { + const selects = [$('#logdock-instance-select')]; + selects.forEach((sel) => { + const previous = sel.value; + sel.innerHTML = ''; + if (state.instances.length === 0) { + sel.appendChild(el('option', { value: '', text: 'No instances available' })); + sel.disabled = true; + return; + } + sel.disabled = false; + state.instances.forEach((inst) => sel.appendChild(el('option', { value: inst.name, text: inst.name }))); + if (state.instances.some((i) => i.name === previous)) sel.value = previous; + }); + if (!state.log.manualSelect && state.currentInstance) { + $('#logdock-instance-select').value = state.currentInstance; + } + onLogInstanceChange(); + } + + async function doStop(name) { + await withFeedback(() => API.stop(name), 'Stop signal sent to ' + name, 'Failed to stop ' + name); + loadInstances(); + } + async function doKill(name) { + const ok = await confirmAction('Kill instance "' + name + '"? This sends SIGKILL immediately.'); + if (!ok) return; + await withFeedback(() => API.kill(name), 'Kill signal sent to ' + name, 'Failed to kill ' + name); + loadInstances(); + } + async function doDelete(name) { + const ok = await confirmAction('Delete instance "' + name + '" and all of its files? This cannot be undone.'); + if (!ok) return; + const deleted = await withFeedback( + () => API.delete(name).then(() => true), 'Deleted ' + name, 'Failed to delete ' + name); + if (deleted && renderedInstance === name) renderedInstance = null; + if (deleted && state.currentInstance === name) showDashboard(); + loadInstances(); + } + + //= ======================================================================== + // Start-instance dialog (RunningConfig) + //= ======================================================================== + let startDialogTarget = null; + let startDialogThen = null; + function openStartDialog(name, andThen) { + startDialogTarget = name; + startDialogThen = andThen || null; + setStatusMessage($('#start-instance-status'), ''); + $('#dialog-start-instance-title').textContent = 'Start Instance — ' + name; + openDialog('dialog-start-instance'); + } + function collectRunningConfig() { + const sessionCount = parseInt($('#start-opt-session-count').value, 10) || 0; + return { + report: $('#start-opt-report').checked, + report_flags: $('#start-opt-report').checked ? ['sessions', 'streams'] : [], + logging: $('#start-opt-logging').checked, + logging_flags: [], + pcap_capture: $('#start-opt-pcap').checked, + session_count: sessionCount, + stream_config: $('#start-opt-stream-config').value.trim(), + metric_flags: ['session_counters', 'interfaces', 'streams'], + }; + } + $('#btn-start-instance-confirm').addEventListener('click', async () => { + const cfg = collectRunningConfig(); + try { + await API.start(startDialogTarget, cfg); + announce('Started ' + startDialogTarget, 'success'); + closeDialog('dialog-start-instance'); + if (startDialogThen) startDialogThen(); + loadInstances(); + if (state.currentInstance === startDialogTarget) refreshInstanceStatus(); + } catch (e) { + showDetailedError($('#start-instance-status'), 'Could not start instance', e.message); + } + }); + + //= ======================================================================== + // JSON Schema driven "New Instance" form + //= ======================================================================== + function resolveSchema(node, root) { + let n = node; + let guard = 0; + while (n && n.$ref && guard++ < 20) { + n = pointerGet(root, n.$ref); + } + if (n && Array.isArray(n.allOf)) { + const merged = Object.assign({}, n); + delete merged.allOf; + n.allOf.forEach((sub) => { + const resolved = resolveSchema(sub, root); + merged.properties = Object.assign({}, merged.properties, resolved.properties); + merged.required = (merged.required || []).concat(resolved.required || []); + if (!merged.type) merged.type = resolved.type; + }); + n = merged; + } + return n || {}; + } + + // Detects the "single item OR array of that item" oneOf pattern used + // throughout the bngblaster schema (network/access/a10nsp/links/lag, + // routing protocol blocks, http/icmp/arp clients, ...). Returns the raw + // (unresolved) item schema for the array alternative, or null. + function oneOfArrayItems(rawSchema, root) { + if (!rawSchema || !Array.isArray(rawSchema.oneOf) || rawSchema.oneOf.length !== 2) return null; + const arrayAlt = rawSchema.oneOf.find((alt) => resolveSchema(alt, root).type === 'array'); + if (!arrayAlt) return null; + const resolvedArrayAlt = resolveSchema(arrayAlt, root); + return arrayAlt.items || resolvedArrayAlt.items || null; + } + + function pointerGet(root, ref) { + if (!ref.startsWith('#/')) return {}; + const parts = ref.slice(2).split('/').map((p) => p.replace(/~1/g, '/').replace(/~0/g, '~')); + let node = root; + for (const p of parts) { + if (node == null) return {}; + node = node[p]; + } + return node || {}; + } + + function isInterfaceField(key) { + return /(^|[-_])interface(name)?$/i.test(key); + } + + // Protocol/unit acronyms used throughout the bngblaster schema that should + // not be rendered with naive title-casing (e.g. "Pppoe", "Ipoe"). + const ACRONYMS = { + a10nsp: 'A10NSP', arp: 'ARP', as: 'AS', bgp: 'BGP', cfm: 'CFM', csnp: 'CSNP', + dhcp: 'DHCP', dhcpv6: 'DHCPv6', df: 'DF', dns1: 'DNS1', dns2: 'DNS2', dsl: 'DSL', + http: 'HTTP', https: 'HTTPS', ia: 'IA', icmp: 'ICMP', id: 'ID', igmp: 'IGMP', + io: 'IO', ip: 'IP', ip6cp: 'IP6CP', ipcp: 'IPCP', ipoe: 'IPoE', ipv4: 'IPv4', + ipv6: 'IPv6', ipv6pd: 'IPv6PD', isis: 'ISIS', l1: 'L1', l2: 'L2', l2tp: 'L2TP', + lacp: 'LACP', lag: 'LAG', lcp: 'LCP', ldp: 'LDP', ldra: 'LDRA', lsa: 'LSA', + lsp: 'LSP', lsr: 'LSR', mac: 'MAC', mrt: 'MRT', mru: 'MRU', mtu: 'MTU', + nat: 'NAT', ont: 'ONT', onu: 'ONU', ospf: 'OSPF', ospfv2: 'OSPFv2', ospfv3: 'OSPFv3', + p2p: 'P2P', pon: 'PON', ppp: 'PPP', pppoe: 'PPPoE', pps: 'pps', psnp: 'PSNP', + qinq: 'QinQ', rx: 'RX', sid: 'SID', sr: 'SR', tcp: 'TCP', tos: 'ToS', ttl: 'TTL', + tun: 'TUN', tx: 'TX', udp: 'UDP', url: 'URL', vlan: 'VLAN', + }; + + function prettyWord(word) { + if (!word) return word; + const canonical = ACRONYMS[word.toLowerCase()]; + if (canonical) return canonical; + return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(); + } + + // Renders a schema key or raw identifier as a human label, applying the + // known protocol/unit acronyms (PPPoE, IPoE, VLAN, ...) instead of naive + // per-word title-casing. + function prettyLabel(text) { + return String(text).split(/[\s_-]+/).filter(Boolean).map(prettyWord).join(' '); + } + + function labelFor(key, schemaNode) { + return (schemaNode && schemaNode.title) || prettyLabel(key); + } + + // Builds a form field for a schema node. Returns { el, getValue } where + // getValue() returns undefined when the field should be omitted from the + // submitted document (untouched optional section, empty optional array...). + function buildField(key, rawSchema, root, required) { + const arrayItems = oneOfArrayItems(rawSchema, root); + if (arrayItems) { + const arraySchema = { type: 'array', items: arrayItems, description: rawSchema.description }; + const field = buildArrayField(key, arraySchema, root, required, nextId('f'), labelFor(key, rawSchema)); + return { + el: field.el, + getValue: field.getValue, + // The real config (this is the "single item OR array of that item" + // oneOf pattern - network/access/a10nsp/links/lag, ...) may store a + // single object rather than an array, since the schema allows + // either. buildArrayField.setValue only understands arrays, so a + // bare object here must be normalized to a one-item array first - + // otherwise loading an existing single-interface config for editing + // would silently discard it, and saving would then write it out + // with that section missing entirely. + setValue: (v) => field.setValue(v === undefined || v === null || Array.isArray(v) ? v : [v]), + }; + } + + const schema = resolveSchema(rawSchema, root); + const type = schema.type || (schema.enum ? 'string' : 'object'); + const id = nextId('f'); + const label = labelFor(key, schema); + + if (type === 'object' && schema.properties) { + return buildObjectField(key, schema, root, required, id, label); + } + if (type === 'array') { + return buildArrayField(key, schema, root, required, id, label); + } + if (schema.enum) { + return buildEnumField(key, schema, required, id, label); + } + if (type === 'boolean') { + return buildBooleanField(key, schema, required, id, label); + } + if (type === 'integer' || type === 'number') { + return buildNumberField(key, schema, required, id, label, type === 'integer'); + } + if (isInterfaceField(key)) { + return buildInterfaceField(key, schema, required, id, label); + } + return buildStringField(key, schema, required, id, label); + } + + function fieldWrap(id, label, required, hint, control) { + const wrap = el('div', { class: 'field' }); + wrap.appendChild(el('label', { for: id }, [ + document.createTextNode(label), + required ? el('span', { class: 'required-mark', 'aria-hidden': 'true', text: '*' }) : null, + ])); + wrap.appendChild(control); + if (hint) wrap.appendChild(el('span', { class: 'hint', text: hint })); + return wrap; + } + + // Leaf fields never bake a schema "default" into the generated config + // unless the field is required: bngblaster already applies its own + // defaults for any key that is simply absent, so silently emitting e.g. + // "cfm-cc": false for a field nobody touched only adds noise and risks + // diverging from the real default later. The default is still shown (as + // placeholder text, or a "Default: ..." hint where a placeholder isn't + // possible) purely for reference. Required fields keep the old + // pre-filled behavior since the config needs that key present regardless. + + function buildStringField(key, schema, required, id, label) { + const input = el('input', { type: 'text', id: id, required: required || null }); + if (schema.default !== undefined) { + if (required) input.value = schema.default; + else input.setAttribute('placeholder', String(schema.default)); + } + if (schema.pattern) input.setAttribute('pattern', schema.pattern); + const wrap = fieldWrap(id, label, required, schema.description, input); + return { + el: wrap, + getValue: () => (input.value.trim() === '' ? undefined : input.value), + setValue: (v) => { input.value = (v === undefined || v === null) ? '' : v; }, + }; + } + + function buildNumberField(key, schema, required, id, label, isInt) { + const input = el('input', { type: 'number', id: id, required: required || null }); + if (schema.minimum !== undefined) input.setAttribute('min', schema.minimum); + if (schema.maximum !== undefined) input.setAttribute('max', schema.maximum); + if (isInt) input.setAttribute('step', '1'); + if (schema.default !== undefined) { + if (required) input.value = schema.default; + else input.setAttribute('placeholder', String(schema.default)); + } + const wrap = fieldWrap(id, label, required, schema.description, input); + return { + el: wrap, + getValue: () => { + if (input.value.trim() === '') return undefined; + const n = Number(input.value); + return Number.isNaN(n) ? undefined : n; + }, + setValue: (v) => { input.value = (v === undefined || v === null) ? '' : v; }, + }; + } + + function buildBooleanField(key, schema, required, id, label) { + const input = el('input', { type: 'checkbox', id: id }); + const hasDefault = schema.default !== undefined; + // Shown for reference either way, but only *counts* as an explicit + // value once required, or once the user actually toggles it. + input.checked = hasDefault && schema.default === true; + let touched = !!required; + input.addEventListener('change', () => { touched = true; }); + const wrap = el('div', { class: 'field checkbox-field' }, [ + input, + el('label', { for: id, text: label }), + ]); + if (!required && hasDefault) wrap.appendChild(el('span', { class: 'hint', text: 'Default: ' + schema.default })); + if (schema.description) wrap.appendChild(el('span', { class: 'hint', text: schema.description })); + return { + el: wrap, + getValue: () => (touched ? input.checked : undefined), + setValue: (v) => { + const has = v !== undefined && v !== null; + touched = has || !!required; + input.checked = has ? !!v : (hasDefault && schema.default === true); + }, + }; + } + + function buildEnumField(key, schema, required, id, label) { + const select = el('select', { id: id, required: required || null }); + if (!required) select.appendChild(el('option', { value: '', text: '(not set)' })); + schema.enum.forEach((v) => select.appendChild(el('option', { value: v, text: String(v) }))); + const hasDefault = schema.default !== undefined; + if (hasDefault) select.value = schema.default; + // Same reference-only treatment as buildBooleanField: a pre-selected + // default shows what will apply, but doesn't count until touched. + let touched = !!required; + select.addEventListener('change', () => { touched = true; }); + const hint = (!required && hasDefault) + ? ('Default: ' + schema.default + (schema.description ? ' — ' + schema.description : '')) + : schema.description; + const wrap = fieldWrap(id, label, required, hint, select); + return { + el: wrap, + getValue: () => (touched && select.value !== '' ? select.value : undefined), + setValue: (v) => { + const has = v !== undefined && v !== null && schema.enum.some((e) => String(e) === String(v)); + touched = has || !!required; + if (has) select.value = String(v); + else select.value = hasDefault ? String(schema.default) : ''; + }, + }; + } + + function buildInterfaceField(key, schema, required, id, label) { + const select = el('select', { id: id }); + if (!required) select.appendChild(el('option', { value: '', text: '(not set)' })); + state.interfaces.forEach((iface) => select.appendChild(el('option', { value: iface.name, text: iface.name + (iface.mtu ? ' (mtu ' + iface.mtu + ')' : '') }))); + select.appendChild(el('option', { value: '__other__', text: 'Other (type manually)…' })); + const manual = el('input', { type: 'text', id: id + '-manual', class: 'visually-hidden', 'aria-label': label + ' (manual value)' }); + select.addEventListener('change', () => { + const isOther = select.value === '__other__'; + manual.classList.toggle('visually-hidden', !isOther); + if (isOther) manual.focus(); + }); + const container = el('div', {}, [select, manual]); + const wrap = fieldWrap(id, label, required, schema.description || 'Populated from the host network interfaces detected by the controller.', container); + return { + el: wrap, + getValue: () => { + if (select.value === '') return undefined; + if (select.value === '__other__') return manual.value.trim() === '' ? undefined : manual.value.trim(); + return select.value; + }, + setValue: (v) => { + if (v === undefined || v === null || v === '') { + select.value = ''; manual.value = ''; manual.classList.add('visually-hidden'); + return; + } + const hasOption = Array.from(select.options).some((o) => o.value === v); + if (hasOption) { + select.value = v; + manual.value = ''; manual.classList.add('visually-hidden'); + } else { + select.value = '__other__'; + manual.value = v; manual.classList.remove('visually-hidden'); + } + }, + }; + } + + function buildObjectField(key, schema, root, required, id, label) { + const requiredChildren = schema.required || []; + const body = el('div', { class: 'form-grid' }); + const children = Object.entries(schema.properties).map(([childKey, childSchema]) => { + const field = buildField(childKey, childSchema, root, requiredChildren.includes(childKey)); + body.appendChild(field.el); + return [childKey, field]; + }); + const details = el('details', { open: required ? '' : null }); + details.appendChild(el('summary', {}, [document.createTextNode(label + (required ? ' *' : ''))])); + if (schema.description) details.appendChild(el('p', { class: 'hint', text: schema.description })); + details.appendChild(body); + return { + el: details, + getValue: () => { + const obj = {}; + let any = false; + children.forEach(([childKey, field]) => { + const v = field.getValue(); + if (v !== undefined) { obj[childKey] = v; any = true; } + }); + if (!any && !required) return undefined; + return obj; + }, + setValue: (v) => { + const has = v !== null && typeof v === 'object'; + children.forEach(([childKey, field]) => { if (field.setValue) field.setValue(has ? v[childKey] : undefined); }); + if (has) details.open = true; + }, + }; + } + + function buildArrayField(key, schema, root, required, id, label) { + const itemSchema = resolveSchema(schema.items || {}, root); + const fieldset = el('fieldset', {}); + fieldset.appendChild(el('legend', { text: label + (required ? ' *' : '') })); + if (schema.description) fieldset.appendChild(el('p', { class: 'hint', text: schema.description })); + + // Enumerated string arrays are rendered as a checkbox group (e.g. flags). + if (itemSchema.type === 'string' && Array.isArray(itemSchema.enum)) { + const boxes = itemSchema.enum.map((v) => { + const cbId = nextId('f'); + const cb = el('input', { type: 'checkbox', id: cbId, value: v }); + fieldset.appendChild(el('div', { class: 'checkbox-field' }, [cb, el('label', { for: cbId, text: v })])); + return cb; + }); + return { + el: fieldset, + getValue: () => { + const values = boxes.filter((b) => b.checked).map((b) => b.value); + return values.length ? values : (required ? [] : undefined); + }, + setValue: (arr) => { + const values = (Array.isArray(arr) ? arr : []).map(String); + boxes.forEach((b) => { b.checked = values.includes(b.value); }); + }, + }; + } + + const itemsContainer = el('div', {}); + fieldset.appendChild(itemsContainer); + const items = []; + + function addItem(initialValue) { + const field = buildField(key + ' item', schema.items || {}, root, false); + if (initialValue !== undefined && field.setValue) field.setValue(initialValue); + const row = el('div', { class: 'array-item' }, [ + field.el, + el('button', { + type: 'button', class: 'btn btn-sm', 'aria-label': 'Remove ' + label + ' item', + text: 'Remove', + onclick: () => { itemsContainer.removeChild(row); const i = items.indexOf(field); if (i >= 0) items.splice(i, 1); }, + }), + ]); + itemsContainer.appendChild(row); + items.push(field); + } + + fieldset.appendChild(el('button', { + type: 'button', class: 'btn btn-sm', text: 'Add ' + label + ' item', + onclick: () => addItem(), + })); + if ((schema.minItems || 0) > 0) { + for (let i = 0; i < schema.minItems; i++) addItem(); + } + + return { + el: fieldset, + getValue: () => { + const values = items.map((f) => f.getValue()).filter((v) => v !== undefined); + return values.length ? values : (required ? [] : undefined); + }, + setValue: (arr) => { + itemsContainer.innerHTML = ''; + items.length = 0; + (Array.isArray(arr) ? arr : []).forEach((v) => addItem(v)); + }, + }; + } + + // newInstanceFields holds the [key, field] pairs of the schema-driven form + // when the schema loaded successfully; null when only JSON editing is + // available (schema missing / failed to load / has no top-level + // properties). + let newInstanceFields = null; + let configMode = 'form'; // 'form' | 'json' + + function debounce(fn, ms) { + let t; + return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); }; + } + + function collectFormJSON() { + const obj = {}; + (newInstanceFields || []).forEach(([k, f]) => { const v = f.getValue(); if (v !== undefined) obj[k] = v; }); + return obj; + } + + function applyJSONToForm(obj) { + (newInstanceFields || []).forEach(([k, f]) => { if (f.setValue) f.setValue(obj ? obj[k] : undefined); }); + } + + function updateSchemaPreview() { + const preview = $('#schema-json-preview'); + if (preview) preview.textContent = JSON.stringify(collectFormJSON(), null, 2); + } + + // Switches the New Instance dialog between the schema-driven form and raw + // JSON editing, synchronizing the two representations at the switch point + // (rather than continuously, which would be surprising while typing). + function switchConfigMode(mode) { + const jsonStatus = $('#schema-json-status'); + if (mode === configMode) return; + if (mode === 'json') { + $('#schema-json-textarea').value = JSON.stringify(collectFormJSON(), null, 2); + placeJSONEditorCaretInsideRoot($('#schema-json-textarea')); + refreshJSONEditor(); + setStatusMessage(jsonStatus, ''); + } else { + let parsed; + try { + const text = $('#schema-json-textarea').value.trim(); + parsed = text ? JSON.parse(text) : {}; + } catch (e) { + setStatusMessage(jsonStatus, 'Cannot switch to the form view: invalid JSON (' + e.message + ').', 'error'); + return; + } + applyJSONToForm(parsed); + updateSchemaPreview(); + setStatusMessage(jsonStatus, ''); + } + configMode = mode; + $('#config-mode-btn-form').setAttribute('aria-pressed', String(mode === 'form')); + $('#config-mode-btn-json').setAttribute('aria-pressed', String(mode === 'json')); + $('#schema-form-root').hidden = mode !== 'form'; + $('#schema-json-root').hidden = mode !== 'json'; + if (mode === 'json') $('#schema-json-textarea').focus(); + } + $('#config-mode-btn-form').addEventListener('click', () => switchConfigMode('form')); + $('#config-mode-btn-json').addEventListener('click', () => switchConfigMode('json')); + + // Hides the Form/JSON toggle and keeps only the JSON editor visible, used + // when there is no usable schema to drive a form from. + function forceJSONOnlyMode() { + newInstanceFields = null; + configMode = 'json'; + $('#config-mode-toggle').hidden = true; + $('#schema-json-root').hidden = false; + } + + //= ======================================================================== + // JSON text editor: schema-aware syntax highlighting, live validation, + // property/enum autocomplete and a cursor-position schema info panel for + // the "Edit as JSON" view. Works even without state.schema (highlighting + // and JSON syntax errors only); schema-driven features (validation beyond + // syntax, autocomplete, info panel) activate once state.schema is set. + //= ======================================================================== + function jsonTokenize(text) { + const tokens = []; + let i = 0; + const n = text.length; + const isDigitStart = (c) => c === '-' || (c >= '0' && c <= '9'); + while (i < n) { + const c = text[i]; + if (c === ' ' || c === '\t' || c === '\n' || c === '\r') { i++; continue; } + if (c === '{' || c === '}' || c === '[' || c === ']' || c === ':' || c === ',') { + tokens.push({ type: 'punct', value: c, start: i, end: i + 1 }); + i++; + continue; + } + if (c === '"') { + const start = i; + i++; + let terminated = false; + while (i < n) { + if (text[i] === '\\') { i += 2; continue; } + if (text[i] === '"') { i++; terminated = true; break; } + if (text[i] === '\n') break; + i++; + } + tokens.push({ type: 'string', start, end: i, terminated }); + continue; + } + if (isDigitStart(c)) { + const start = i; + i++; + while (i < n && /[0-9.eE+-]/.test(text[i])) i++; + tokens.push({ type: 'number', start, end: i }); + continue; + } + if (/[a-zA-Z_]/.test(c)) { + const start = i; + i++; + while (i < n && /[a-zA-Z_0-9]/.test(text[i])) i++; + const word = text.slice(start, i); + const type = (word === 'true' || word === 'false') ? 'boolean' : (word === 'null' ? 'null' : 'ident'); + tokens.push({ type, value: word, start, end: i }); + continue; + } + tokens.push({ type: 'invalid', value: c, start: i, end: i + 1 }); + i++; + } + return tokens; + } + + function decodeJSONStringToken(text, tok) { + let raw = text.slice(tok.start, tok.end); + if (!tok.terminated) raw += '"'; + try { + return JSON.parse(raw); + } catch (e) { + return raw.slice(1, -1); + } + } + + // Lenient recursive-descent JSON parser with error recovery: instead of + // throwing on the first problem (like JSON.parse), it records an error and + // keeps going, so the rest of an in-progress edit still gets highlighted + // and validated. Produces an AST annotated with source offsets, and tags + // object-key string tokens with role:'key' (used to tell keys from string + // values apart when rendering syntax highlighting). + function jsonParseLenient(text) { + const tokens = jsonTokenize(text); + let pos = 0; + const errors = []; + const peek = () => tokens[pos]; + const next = () => tokens[pos++]; + const err = (tok, msg) => errors.push({ start: tok ? tok.start : text.length, end: tok ? tok.end : text.length, message: msg }); + + function parseValue() { + const tok = peek(); + if (!tok) { err(null, 'Unexpected end of input.'); return null; } + if (tok.type === 'punct' && tok.value === '{') return parseObject(); + if (tok.type === 'punct' && tok.value === '[') return parseArray(); + if (tok.type === 'string') { next(); return { type: 'string', start: tok.start, end: tok.end, raw: tok }; } + if (tok.type === 'number') { next(); return { type: 'number', start: tok.start, end: tok.end, value: Number(text.slice(tok.start, tok.end)) }; } + if (tok.type === 'boolean') { next(); return { type: 'boolean', start: tok.start, end: tok.end, value: tok.value === 'true' }; } + if (tok.type === 'null') { next(); return { type: 'null', start: tok.start, end: tok.end }; } + err(tok, 'Unexpected token "' + (tok.value || text.slice(tok.start, tok.end)) + '".'); + next(); + return null; + } + function parseObject() { + const open = next(); + const node = { type: 'object', start: open.start, end: open.end, entries: [] }; + let first = true; + for (;;) { + let tok = peek(); + if (!tok) { err(null, 'Unterminated object.'); break; } + if (tok.type === 'punct' && tok.value === '}') { next(); node.end = tok.end; break; } + if (!first) { + if (tok.type === 'punct' && tok.value === ',') { + next(); + tok = peek(); + if (tok && tok.type === 'punct' && tok.value === '}') err(tok, 'Trailing comma is not allowed.'); + } else { + err(tok, 'Expected "," or "}".'); + } + } + first = false; + if (!tok) break; + if (tok.type === 'punct' && tok.value === '}') { next(); node.end = tok.end; break; } + if (tok.type !== 'string') { err(tok, 'Expected a property name.'); next(); continue; } + const keyTok = next(); + keyTok.role = 'key'; + const keyName = decodeJSONStringToken(text, keyTok); + const colon = peek(); + if (colon && colon.type === 'punct' && colon.value === ':') next(); + else err(colon, 'Expected ":".'); + const valueNode = parseValue(); + node.entries.push({ key: keyName, keyStart: keyTok.start, keyEnd: keyTok.end, value: valueNode }); + node.end = valueNode ? valueNode.end : keyTok.end; + } + return node; + } + function parseArray() { + const open = next(); + const node = { type: 'array', start: open.start, end: open.end, items: [] }; + let first = true; + for (;;) { + let tok = peek(); + if (!tok) { err(null, 'Unterminated array.'); break; } + if (tok.type === 'punct' && tok.value === ']') { next(); node.end = tok.end; break; } + if (!first) { + if (tok.type === 'punct' && tok.value === ',') { + next(); + tok = peek(); + if (tok && tok.type === 'punct' && tok.value === ']') err(tok, 'Trailing comma is not allowed.'); + } else { + err(tok, 'Expected "," or "]".'); + } + } + first = false; + if (tok && tok.type === 'punct' && tok.value === ']') { next(); node.end = tok.end; break; } + const itemNode = parseValue(); + node.items.push(itemNode); + node.end = itemNode ? itemNode.end : node.end; + } + return node; + } + + let root = null; + if (tokens.length) { + root = parseValue(); + if (pos < tokens.length) err(peek(), 'Unexpected trailing content.'); + } + return { root, errors, tokens }; + } + + function jsonSchemaTypesOf(schema) { + if (!schema) return null; + if (schema.type) return Array.isArray(schema.type) ? schema.type : [schema.type]; + return null; + } + + // Validates a parsed AST node against a (possibly $ref/allOf/oneOf-array) + // schema, appending {start, end, message} problems to `out`. Deliberately + // covers the constraint kinds actually used by the bngblaster schema + // (type, enum, required, additionalProperties, pattern/length, min/max, + // array size) rather than the full JSON Schema spec. + function validateJSONAgainstSchema(text, node, rawSchema, root, path, out) { + if (!node || !rawSchema) return; + const arrayAlt = oneOfArrayItems(rawSchema, root); + if (arrayAlt) { + if (node.type === 'array') { validateJSONAgainstSchema(text, node, { type: 'array', items: arrayAlt }, root, path, out); return; } + validateJSONAgainstSchema(text, node, arrayAlt, root, path, out); + return; + } + const schema = resolveSchema(rawSchema, root); + const types = jsonSchemaTypesOf(schema); + const actual = node.type; + const label = path || '(root)'; + if (types) { + const ok = types.includes(actual) || (actual === 'number' && types.includes('integer') && Number.isInteger(node.value)); + if (!ok) { + const at = (actual === 'object' || actual === 'array') ? [node.start, node.start + 1] : [node.start, node.end]; + out.push({ start: at[0], end: at[1], message: label + ': expected ' + types.join(' or ') + ', got ' + actual + '.' }); + return; + } + } + if (schema.enum) { + const val = actual === 'string' ? decodeJSONStringToken(text, node.raw) : (actual === 'number' || actual === 'boolean' ? node.value : null); + if (!schema.enum.some((e) => e === val)) { + out.push({ start: node.start, end: node.end, message: label + ': must be one of ' + schema.enum.map((e) => JSON.stringify(e)).join(', ') + '.' }); + } + } + if (actual === 'string') { + const val = decodeJSONStringToken(text, node.raw); + if (schema.pattern) { + try { + if (!new RegExp(schema.pattern).test(val)) out.push({ start: node.start, end: node.end, message: label + ': does not match pattern ' + schema.pattern + '.' }); + } catch (e) { /* invalid pattern in the schema itself - nothing to check */ } + } + if (schema.minLength !== undefined && val.length < schema.minLength) out.push({ start: node.start, end: node.end, message: label + ': must be at least ' + schema.minLength + ' characters.' }); + if (schema.maxLength !== undefined && val.length > schema.maxLength) out.push({ start: node.start, end: node.end, message: label + ': must be at most ' + schema.maxLength + ' characters.' }); + } + if (actual === 'number') { + if (schema.minimum !== undefined && node.value < schema.minimum) out.push({ start: node.start, end: node.end, message: label + ': must be ≥ ' + schema.minimum + '.' }); + if (schema.maximum !== undefined && node.value > schema.maximum) out.push({ start: node.start, end: node.end, message: label + ': must be ≤ ' + schema.maximum + '.' }); + } + if (actual === 'object') { + const props = schema.properties || {}; + const required = schema.required || []; + const seen = new Set(); + node.entries.forEach((entry) => { + seen.add(entry.key); + const childSchema = props[entry.key]; + if (!childSchema) { + if (schema.additionalProperties === false) { + out.push({ start: entry.keyStart, end: entry.keyEnd, message: label + ': unknown property "' + entry.key + '".' }); + } + return; + } + if (entry.value) validateJSONAgainstSchema(text, entry.value, childSchema, root, path ? path + '.' + entry.key : entry.key, out); + }); + required.forEach((key) => { + if (!seen.has(key)) { + const at = node.end > node.start ? [Math.max(node.start, node.end - 1), node.end] : [node.start, node.end]; + out.push({ start: at[0], end: at[1], message: label + ': missing required property "' + key + '".' }); + } + }); + } + if (actual === 'array') { + const itemSchema = schema.items; + if (schema.minItems !== undefined && node.items.length < schema.minItems) out.push({ start: node.start, end: node.start + 1, message: label + ': must have at least ' + schema.minItems + ' item(s).' }); + if (schema.maxItems !== undefined && node.items.length > schema.maxItems) out.push({ start: node.start, end: node.start + 1, message: label + ': must have at most ' + schema.maxItems + ' item(s).' }); + if (itemSchema) node.items.forEach((item, idx) => { if (item) validateJSONAgainstSchema(text, item, itemSchema, root, path + '[' + idx + ']', out); }); + } + } + + // Walks the token stream up to `offset` with a small stack machine + // (mirroring the object/array nesting) to determine the JSON path and + // schema in scope at the cursor, tolerating an in-progress/invalid + // document around the cursor itself (the token currently being typed, + // "partial", is deliberately excluded from the walk). + function computeJSONCursorContext(text, offset, rootSchema) { + const tokens = jsonTokenize(text); + const partial = tokens.find((t) => { + if (t.type === 'punct') return false; + if (t.start < offset && offset < t.end) return true; + if (t.type === 'string' && !t.terminated && t.start < offset && offset <= t.end) return true; + return false; + }) || null; + const consumed = tokens.filter((t) => t !== partial && t.end <= offset); + + const rootEff = rootSchema ? resolveSchema(rootSchema, rootSchema) : null; + const stack = [{ kind: 'root', rawSchema: rootSchema, effective: rootEff, path: '', keys: null, index: 0 }]; + let pendingKey = null; + let expect = 'value'; + + function schemaForChild(top, key) { + if (top.kind === 'object') { + const props = (top.effective && top.effective.properties) || {}; + if (Object.prototype.hasOwnProperty.call(props, key)) return props[key]; + if (top.effective && top.effective.additionalProperties && typeof top.effective.additionalProperties === 'object') return top.effective.additionalProperties; + return null; + } + if (top.kind === 'array') return (top.effective && top.effective.items) || null; + return null; + } + + consumed.forEach((t) => { + const top = stack[stack.length - 1]; + if (t.type === 'punct') { + if (t.value === '{' || t.value === '[') { + const isObj = t.value === '{'; + const key = pendingKey; + const idx = top.index || 0; + const childRaw = top.kind === 'root' ? top.rawSchema : schemaForChild(top, key); + let effChild = childRaw ? resolveSchema(childRaw, rootSchema) : null; + const arrAlt = childRaw ? oneOfArrayItems(childRaw, rootSchema) : null; + if (arrAlt) effChild = isObj ? resolveSchema(arrAlt, rootSchema) : { type: 'array', items: arrAlt }; + const childPath = top.kind === 'array' ? top.path + '[' + idx + ']' : (top.path ? top.path + '.' + key : (key || '')); + stack.push({ kind: isObj ? 'object' : 'array', rawSchema: childRaw, effective: effChild, path: childPath, keys: isObj ? new Set() : null, index: 0 }); + pendingKey = null; + expect = isObj ? 'key-or-close' : 'value-or-close'; + } else if (t.value === '}' || t.value === ']') { + if (stack.length > 1) stack.pop(); + expect = 'comma-or-close'; + } else if (t.value === ':') { + expect = 'value'; + } else if (t.value === ',') { + if (top.kind === 'array') top.index = (top.index || 0) + 1; + expect = top.kind === 'object' ? 'key-or-close' : 'value-or-close'; + pendingKey = null; + } + } else if (t.type === 'string') { + if (top.kind === 'object' && expect === 'key-or-close') { + pendingKey = decodeJSONStringToken(text, t); + top.keys.add(pendingKey); + expect = 'colon'; + } else { + expect = 'comma-or-close'; + } + } else { + expect = 'comma-or-close'; + } + }); + + return { tokens, partial, stack, top: stack[stack.length - 1], pendingKey, expect }; + } + + // Property-name / enum-value suggestions for the cursor's current context, + // or null when nothing sensible applies (e.g. no schema loaded, or the + // cursor sits somewhere autocomplete doesn't help such as mid-punctuation). + function jsonAutocompleteSuggestions(text, offset, rootSchema) { + if (!rootSchema) return null; + const ctx = computeJSONCursorContext(text, offset, rootSchema); + const top = ctx.top; + const partial = ctx.partial; + + // Property-name hints apply right after "{" or "," (key-or-close), but + // also the moment the user starts typing a fresh '"' right after a + // value with no separating comma yet (comma-or-close) - they've clearly + // started a new key, the missing comma is a separate (already-flagged) + // syntax error, not a reason to withhold the hint. + const atKeyPosition = top.kind === 'object' + && (ctx.expect === 'key-or-close' || (ctx.expect === 'comma-or-close' && partial && partial.type === 'string')); + if (atKeyPosition) { + const props = (top.effective && top.effective.properties) || {}; + const required = (top.effective && top.effective.required) || []; + const prefix = partial ? text.slice(partial.start + 1, Math.min(offset, partial.end)) : ''; + const items = Object.keys(props) + .filter((k) => !top.keys.has(k)) + .filter((k) => k.toLowerCase().startsWith(prefix.toLowerCase())) + .sort((a, b) => { + if (required.includes(a) !== required.includes(b)) return required.includes(a) ? -1 : 1; + return a.localeCompare(b); + }) + .map((k) => ({ + label: k, + required: required.includes(k), + description: props[k].description || resolveSchema(props[k], rootSchema).description || resolveSchema(props[k], rootSchema).title || '', + })); + return { range: partial ? [partial.start, offset] : [offset, offset], items }; + } + + if (ctx.expect === 'value') { + let valueSchema = null; + if (top.kind === 'object' && ctx.pendingKey) valueSchema = (top.effective && top.effective.properties && top.effective.properties[ctx.pendingKey]) || null; + else if (top.kind === 'array') valueSchema = (top.effective && top.effective.items) || null; + else if (top.kind === 'root') valueSchema = top.rawSchema; + if (!valueSchema) return null; + const resolved = resolveSchema(valueSchema, rootSchema); + const isStringPartial = partial && partial.type === 'string'; + const isIdentPartial = partial && partial.type === 'ident'; + if (resolved.enum) { + const prefix = isStringPartial ? text.slice(partial.start + 1, Math.min(offset, partial.end)) : (isIdentPartial ? text.slice(partial.start, offset) : ''); + const items = resolved.enum + .filter((v) => String(v).toLowerCase().startsWith(prefix.toLowerCase())) + .map((v) => ({ label: String(v), description: resolved.description || '' })); + return { range: partial ? [partial.start, offset] : [offset, offset], items }; + } + if (resolved.type === 'boolean' && (isIdentPartial || !partial)) { + const prefix = isIdentPartial ? text.slice(partial.start, offset) : ''; + const items = ['true', 'false'].filter((v) => v.startsWith(prefix)).map((v) => ({ label: v, description: '' })); + return { range: partial ? [partial.start, offset] : [offset, offset], items }; + } + return null; + } + return null; + } + + // Resolves what schema/path applies exactly at the cursor, for the + // read-only "field info" panel that updates as the caret moves. + function describeJSONCursorContext(ctx, rootSchema) { + const top = ctx.top; + if (ctx.expect === 'value') { + if (top.kind === 'object' && ctx.pendingKey) { + const raw = (top.effective && top.effective.properties && top.effective.properties[ctx.pendingKey]) || null; + return { path: top.path ? top.path + '.' + ctx.pendingKey : ctx.pendingKey, raw, schema: raw ? resolveSchema(raw, rootSchema) : null }; + } + if (top.kind === 'array') { + const raw = (top.effective && top.effective.items) || null; + return { path: top.path + '[' + (top.index || 0) + ']', raw, schema: raw ? resolveSchema(raw, rootSchema) : null }; + } + if (top.kind === 'root') return { path: '(root)', raw: top.rawSchema, schema: resolveSchema(top.rawSchema, rootSchema) }; + } + return { path: top.path || '(root)', raw: top.rawSchema, schema: top.effective }; + } + + function jsonLineColAt(text, offset) { + let line = 1; + let col = 1; + for (let i = 0; i < offset && i < text.length; i++) { + if (text[i] === '\n') { line++; col = 1; } else col++; + } + return { line, col }; + } + + function renderJSONHighlightHTML(text, tokens, errorRanges) { + let html = ''; + let last = 0; + const overlapsError = (s, e) => errorRanges.some((r) => s < r.end && e > r.start); + tokens.forEach((t) => { + if (t.start > last) html += escapeHtml(text.slice(last, t.start)); + let cls; + if (t.type === 'punct') cls = 'jt-punct'; + else if (t.type === 'string') cls = t.role === 'key' ? 'jt-key' : 'jt-string'; + else if (t.type === 'number') cls = 'jt-number'; + else if (t.type === 'boolean') cls = 'jt-boolean'; + else if (t.type === 'null') cls = 'jt-null'; + else cls = 'jt-plain'; + if (overlapsError(t.start, t.end)) cls += ' jt-error'; + html += '' + escapeHtml(text.slice(t.start, t.end)) + ''; + last = t.end; + }); + if (last < text.length) html += escapeHtml(text.slice(last)); + if (text.length === 0 || text.endsWith('\n')) html += '\n'; + return html; + } + + // Mirrors the textarea's text-affecting CSS onto a hidden, off-screen div + // so a marker span inserted at a given character offset reports the pixel + // position the caret would render at - the standard technique for + // positioning UI (here, the autocomplete popup) relative to caret in a + // plain