From df53b00bce3c4ec737c2e8b727ca6d38f27b3d9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Su=C3=B1=C3=A9?= Date: Sat, 28 Feb 2026 23:36:33 +0100 Subject: [PATCH 01/13] fix(ui): correct http-plain.conf data alias path The HTTP-plain config used /tmp/l2radar/ while default.conf (HTTPS) correctly uses /var/lib/l2radar/. Align to match the named volume mount point. Co-Authored-By: Claude Opus 4.6 --- ui/nginx/http-plain.conf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/nginx/http-plain.conf b/ui/nginx/http-plain.conf index ae633a0..f85ffd6 100644 --- a/ui/nginx/http-plain.conf +++ b/ui/nginx/http-plain.conf @@ -18,7 +18,7 @@ server { } location /data/ { - alias /tmp/l2radar/; + alias /var/lib/l2radar/; autoindex on; autoindex_format json; add_header Cache-Control "no-cache"; From 2a5eec8719a48958a1990aac1ab17b89ec2c595c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Su=C3=B1=C3=A9?= Date: Sat, 28 Feb 2026 23:39:58 +0100 Subject: [PATCH 02/13] refactor(l2rctl): extract shared containers package Consolidate duplicated container constants (Probe, UI), ParseTarget, and container inspect logic into internal/containers. Removes DRY violations across start, stop, status, and dump packages. Co-Authored-By: Claude Opus 4.6 --- l2rctl/cmd/l2rctl/cli/start.go | 3 +- l2rctl/cmd/l2rctl/cli/stop.go | 3 +- l2rctl/internal/containers/containers.go | 70 ++++++++++++ l2rctl/internal/containers/containers_test.go | 101 ++++++++++++++++++ l2rctl/internal/dump/dump.go | 5 +- l2rctl/internal/start/probe.go | 5 +- l2rctl/internal/start/start.go | 69 ++---------- l2rctl/internal/start/start_test.go | 33 ------ l2rctl/internal/start/ui.go | 5 +- l2rctl/internal/start/volume_test.go | 5 +- l2rctl/internal/status/status.go | 34 ++---- l2rctl/internal/stop/stop.go | 32 +----- l2rctl/internal/stop/stop_test.go | 7 +- 13 files changed, 210 insertions(+), 162 deletions(-) create mode 100644 l2rctl/internal/containers/containers.go create mode 100644 l2rctl/internal/containers/containers_test.go diff --git a/l2rctl/cmd/l2rctl/cli/start.go b/l2rctl/cmd/l2rctl/cli/start.go index cdb578e..6270a5e 100644 --- a/l2rctl/cmd/l2rctl/cli/start.go +++ b/l2rctl/cmd/l2rctl/cli/start.go @@ -5,6 +5,7 @@ import ( "os" "github.com/msune/l2radar/l2rctl/internal/auth" + "github.com/msune/l2radar/l2rctl/internal/containers" "github.com/msune/l2radar/l2rctl/internal/start" "github.com/spf13/cobra" ) @@ -77,7 +78,7 @@ func init() { func runStartOrInstall(cmd *cobra.Command, args []string, restartPolicy string) error { r := NewRunner() - target, err := start.ParseTarget(args) + target, err := containers.ParseTarget(args) if err != nil { return err } diff --git a/l2rctl/cmd/l2rctl/cli/stop.go b/l2rctl/cmd/l2rctl/cli/stop.go index 6bf3392..b770132 100644 --- a/l2rctl/cmd/l2rctl/cli/stop.go +++ b/l2rctl/cmd/l2rctl/cli/stop.go @@ -1,6 +1,7 @@ package cli import ( + "github.com/msune/l2radar/l2rctl/internal/containers" "github.com/msune/l2radar/l2rctl/internal/stop" "github.com/spf13/cobra" ) @@ -16,7 +17,7 @@ var stopCmd = &cobra.Command{ Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { r := NewRunner() - target, err := stop.ParseTarget(args) + target, err := containers.ParseTarget(args) if err != nil { return err } diff --git a/l2rctl/internal/containers/containers.go b/l2rctl/internal/containers/containers.go new file mode 100644 index 0000000..7c4806e --- /dev/null +++ b/l2rctl/internal/containers/containers.go @@ -0,0 +1,70 @@ +package containers + +import ( + "encoding/json" + "fmt" + + "github.com/msune/l2radar/l2rctl/internal/docker" +) + +const ( + // Probe is the container name for the l2radar probe. + Probe = "l2radar" + // UI is the container name for the l2radar UI. + UI = "l2radar-ui" +) + +var validTargets = map[string]bool{ + "all": true, + "probe": true, + "ui": true, +} + +// ParseTarget extracts the target from args (default: "all"). +func ParseTarget(args []string) (string, error) { + if len(args) == 0 { + return "all", nil + } + t := args[0] + if !validTargets[t] { + return "", fmt.Errorf("invalid target: %s (must be all, probe, or ui)", t) + } + return t, nil +} + +// State holds the result of inspecting a container. +type State struct { + Status string + StartedAt string + Found bool +} + +// inspectResponse mirrors the relevant docker inspect JSON fields. +type inspectResponse struct { + State struct { + Status string `json:"Status"` + StartedAt string `json:"StartedAt"` + } `json:"State"` +} + +// Inspect checks a container's state via docker inspect. +func Inspect(r docker.Runner, name string) State { + stdout, _, err := r.Run("inspect", "--type", "container", name) + if err != nil { + return State{Status: "not found", Found: false} + } + var infos []inspectResponse + if err := json.Unmarshal([]byte(stdout), &infos); err != nil || len(infos) == 0 { + return State{Status: "not found", Found: false} + } + info := infos[0] + started := info.State.StartedAt + if started == "" { + started = "-" + } + return State{ + Status: info.State.Status, + StartedAt: started, + Found: true, + } +} diff --git a/l2rctl/internal/containers/containers_test.go b/l2rctl/internal/containers/containers_test.go new file mode 100644 index 0000000..930ed54 --- /dev/null +++ b/l2rctl/internal/containers/containers_test.go @@ -0,0 +1,101 @@ +package containers + +import ( + "fmt" + "testing" + + "github.com/msune/l2radar/l2rctl/internal/docker" +) + +func TestParseTarget(t *testing.T) { + tests := []struct { + name string + args []string + want string + wantErr bool + }{ + {"default is all", nil, "all", false}, + {"explicit all", []string{"all"}, "all", false}, + {"probe", []string{"probe"}, "probe", false}, + {"ui", []string{"ui"}, "ui", false}, + {"invalid", []string{"bogus"}, "", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseTarget(tt.args) + if tt.wantErr { + if err == nil { + t.Fatal("expected error") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tt.want { + t.Errorf("got %q, want %q", got, tt.want) + } + }) + } +} + +func TestInspect_Found(t *testing.T) { + m := &docker.MockRunner{ + StdoutFn: func(args []string) string { + return `[{"State":{"Status":"running","StartedAt":"2026-02-14T14:00:00Z"}}]` + }, + } + s := Inspect(m, "l2radar") + if !s.Found { + t.Fatal("expected Found=true") + } + if s.Status != "running" { + t.Errorf("got status %q, want %q", s.Status, "running") + } + if s.StartedAt != "2026-02-14T14:00:00Z" { + t.Errorf("got StartedAt %q, want %q", s.StartedAt, "2026-02-14T14:00:00Z") + } +} + +func TestInspect_NotFound(t *testing.T) { + m := &docker.MockRunner{ + ErrFn: func(args []string) error { + return fmt.Errorf("Error: No such container: l2radar") + }, + } + s := Inspect(m, "l2radar") + if s.Found { + t.Fatal("expected Found=false") + } + if s.Status != "not found" { + t.Errorf("got status %q, want %q", s.Status, "not found") + } +} + +func TestInspect_EmptyStartedAt(t *testing.T) { + m := &docker.MockRunner{ + StdoutFn: func(args []string) string { + return `[{"State":{"Status":"created","StartedAt":""}}]` + }, + } + s := Inspect(m, "l2radar") + if !s.Found { + t.Fatal("expected Found=true") + } + if s.StartedAt != "-" { + t.Errorf("got StartedAt %q, want %q", s.StartedAt, "-") + } +} + +func TestInspect_InvalidJSON(t *testing.T) { + m := &docker.MockRunner{ + StdoutFn: func(args []string) string { + return `not json` + }, + } + s := Inspect(m, "l2radar") + if s.Found { + t.Fatal("expected Found=false for invalid JSON") + } +} diff --git a/l2rctl/internal/dump/dump.go b/l2rctl/internal/dump/dump.go index 031f9ec..9f5b64d 100644 --- a/l2rctl/internal/dump/dump.go +++ b/l2rctl/internal/dump/dump.go @@ -3,11 +3,10 @@ package dump import ( "fmt" + "github.com/msune/l2radar/l2rctl/internal/containers" "github.com/msune/l2radar/l2rctl/internal/docker" ) -const ProbeContainer = "l2radar" - // Opts holds dump command options. type Opts struct { Iface string @@ -23,7 +22,7 @@ func Dump(r docker.Runner, opts Opts) error { return fmt.Errorf("invalid output format %q (supported: table, json)", opts.Output) } - args := []string{"exec", ProbeContainer, "/l2radar", "dump", "--iface", opts.Iface} + args := []string{"exec", containers.Probe, "/l2radar", "dump", "--iface", opts.Iface} if opts.Output != "" { args = append(args, "-o", opts.Output) } diff --git a/l2rctl/internal/start/probe.go b/l2rctl/internal/start/probe.go index 0cb2ee0..88dd788 100644 --- a/l2rctl/internal/start/probe.go +++ b/l2rctl/internal/start/probe.go @@ -3,6 +3,7 @@ package start import ( "fmt" + "github.com/msune/l2radar/l2rctl/internal/containers" "github.com/msune/l2radar/l2rctl/internal/docker" ) @@ -20,7 +21,7 @@ type ProbeOpts struct { // StartProbe starts the l2radar probe container. func StartProbe(r docker.Runner, opts ProbeOpts) error { - if err := ensureNotRunning(r, ProbeContainer); err != nil { + if err := ensureNotRunning(r, containers.Probe); err != nil { return err } if err := pullImage(r, opts.Image); err != nil { @@ -32,7 +33,7 @@ func StartProbe(r docker.Runner, opts ProbeOpts) error { "--network=host", "-v", "/sys/fs/bpf:/sys/fs/bpf", "-v", fmt.Sprintf("%s:%s", opts.VolumeName, opts.ExportDir), - "--name", ProbeContainer, + "--name", containers.Probe, } if opts.RestartPolicy != "" { diff --git a/l2rctl/internal/start/start.go b/l2rctl/internal/start/start.go index a123ebb..0bba3a4 100644 --- a/l2rctl/internal/start/start.go +++ b/l2rctl/internal/start/start.go @@ -1,75 +1,21 @@ package start import ( - "encoding/json" "fmt" "io" "strings" + "github.com/msune/l2radar/l2rctl/internal/containers" "github.com/msune/l2radar/l2rctl/internal/docker" ) -const ( - ProbeContainer = "l2radar" - UIContainer = "l2radar-ui" -) - -var validTargets = map[string]bool{ - "all": true, - "probe": true, - "ui": true, -} - -// ParseTarget extracts the target from args (default: "all"). -func ParseTarget(args []string) (string, error) { - if len(args) == 0 { - return "all", nil - } - t := args[0] - if !validTargets[t] { - return "", fmt.Errorf("invalid target: %s (must be all, probe, or ui)", t) - } - return t, nil -} - -// containerState holds docker inspect state. -type containerState struct { - State struct { - Status string `json:"Status"` - } `json:"State"` -} - -// checkContainer checks if a container exists and its state. -// Returns: "running", "stopped", or "notfound". -func checkContainer(r docker.Runner, name string) (string, error) { - stdout, _, err := r.Run("inspect", "--type", "container", name) - if err != nil { - return "notfound", nil - } - var states []containerState - if err := json.Unmarshal([]byte(stdout), &states); err != nil { - return "notfound", nil - } - if len(states) == 0 { - return "notfound", nil - } - status := states[0].State.Status - if status == "running" { - return "running", nil - } - return "stopped", nil -} - // ensureNotRunning checks container state and removes stopped containers. func ensureNotRunning(r docker.Runner, name string) error { - state, err := checkContainer(r, name) - if err != nil { - return err - } - switch state { - case "running": + s := containers.Inspect(r, name) + switch { + case s.Found && s.Status == "running": return fmt.Errorf("container %q is already running (stop it first)", name) - case "stopped": + case s.Found: if _, _, err := r.Run("rm", name); err != nil { return fmt.Errorf("remove stopped container %q: %w", name, err) } @@ -97,8 +43,9 @@ func pullImage(r docker.Runner, image string) error { // when the volume does not exist. func EnsureCleanVolume(r docker.Runner, volumeName string, warn io.Writer) error { // If either container is running, the volume is in active use — leave it alone. - for _, name := range []string{ProbeContainer, UIContainer} { - if state, _ := checkContainer(r, name); state == "running" { + for _, name := range []string{containers.Probe, containers.UI} { + s := containers.Inspect(r, name) + if s.Found && s.Status == "running" { return nil } } diff --git a/l2rctl/internal/start/start_test.go b/l2rctl/internal/start/start_test.go index 3b4791a..dc751c9 100644 --- a/l2rctl/internal/start/start_test.go +++ b/l2rctl/internal/start/start_test.go @@ -49,36 +49,3 @@ func TestPullImage_PullFailsAndLocalMissing(t *testing.T) { t.Fatal("expected error when pull fails and image not local") } } - -func TestParseTarget(t *testing.T) { - tests := []struct { - name string - args []string - want string - wantErr bool - }{ - {"default is all", nil, "all", false}, - {"explicit all", []string{"all"}, "all", false}, - {"probe", []string{"probe"}, "probe", false}, - {"ui", []string{"ui"}, "ui", false}, - {"invalid", []string{"bogus"}, "", true}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := ParseTarget(tt.args) - if tt.wantErr { - if err == nil { - t.Fatal("expected error") - } - return - } - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if got != tt.want { - t.Errorf("got %q, want %q", got, tt.want) - } - }) - } -} diff --git a/l2rctl/internal/start/ui.go b/l2rctl/internal/start/ui.go index 073d854..34644e4 100644 --- a/l2rctl/internal/start/ui.go +++ b/l2rctl/internal/start/ui.go @@ -3,6 +3,7 @@ package start import ( "fmt" + "github.com/msune/l2radar/l2rctl/internal/containers" "github.com/msune/l2radar/l2rctl/internal/docker" ) @@ -24,7 +25,7 @@ type UIOpts struct { // StartUI starts the l2radar-ui container. func StartUI(r docker.Runner, opts UIOpts) error { - if err := ensureNotRunning(r, UIContainer); err != nil { + if err := ensureNotRunning(r, containers.UI); err != nil { return err } if err := pullImage(r, opts.Image); err != nil { @@ -34,7 +35,7 @@ func StartUI(r docker.Runner, opts UIOpts) error { args := []string{"run", "-d", "-v", fmt.Sprintf("%s:%s:ro", opts.VolumeName, opts.ExportDir), "-p", fmt.Sprintf("%s:%d:443", opts.Bind, opts.HTTPSPort), - "--name", UIContainer, + "--name", containers.UI, } if opts.RestartPolicy != "" { diff --git a/l2rctl/internal/start/volume_test.go b/l2rctl/internal/start/volume_test.go index 4efb4f1..243e80c 100644 --- a/l2rctl/internal/start/volume_test.go +++ b/l2rctl/internal/start/volume_test.go @@ -6,6 +6,7 @@ import ( "strings" "testing" + "github.com/msune/l2radar/l2rctl/internal/containers" "github.com/msune/l2radar/l2rctl/internal/docker" ) @@ -74,7 +75,7 @@ func TestEnsureCleanVolume_ProbeRunning(t *testing.T) { // Probe is running → skip volume rm even if volume exists. m := &docker.MockRunner{ StdoutFn: func(args []string) string { - if len(args) >= 2 && args[0] == "inspect" && args[len(args)-1] == ProbeContainer { + if len(args) >= 2 && args[0] == "inspect" && args[len(args)-1] == containers.Probe { return `[{"State":{"Status":"running"}}]` } if len(args) >= 2 && args[0] == "volume" && args[1] == "inspect" { @@ -100,7 +101,7 @@ func TestEnsureCleanVolume_UIRunning(t *testing.T) { // UI is running → skip volume rm even if volume exists. m := &docker.MockRunner{ StdoutFn: func(args []string) string { - if len(args) >= 2 && args[0] == "inspect" && args[len(args)-1] == UIContainer { + if len(args) >= 2 && args[0] == "inspect" && args[len(args)-1] == containers.UI { return `[{"State":{"Status":"running"}}]` } if len(args) >= 2 && args[0] == "volume" && args[1] == "inspect" { diff --git a/l2rctl/internal/status/status.go b/l2rctl/internal/status/status.go index e53a5ea..a7bcd23 100644 --- a/l2rctl/internal/status/status.go +++ b/l2rctl/internal/status/status.go @@ -1,54 +1,34 @@ package status import ( - "encoding/json" "fmt" "strings" "text/tabwriter" + "github.com/msune/l2radar/l2rctl/internal/containers" "github.com/msune/l2radar/l2rctl/internal/docker" ) -const ( - ProbeContainer = "l2radar" - UIContainer = "l2radar-ui" -) - -type containerInfo struct { - State struct { - Status string `json:"Status"` - StartedAt string `json:"StartedAt"` - } `json:"State"` -} - type row struct { name string status string started string } -func inspectContainer(r docker.Runner, name string) row { - stdout, _, err := r.Run("inspect", "--type", "container", name) - if err != nil { - return row{name: name, status: "not found", started: "-"} - } - var infos []containerInfo - if err := json.Unmarshal([]byte(stdout), &infos); err != nil || len(infos) == 0 { - return row{name: name, status: "not found", started: "-"} - } - info := infos[0] - started := info.State.StartedAt +func inspectRow(r docker.Runner, name string) row { + s := containers.Inspect(r, name) + started := s.StartedAt if started == "" { started = "-" } - return row{name: name, status: info.State.Status, started: started} + return row{name: name, status: s.Status, started: started} } // Status returns a formatted table of container statuses. func Status(r docker.Runner) (string, error) { rows := []row{ - inspectContainer(r, ProbeContainer), - inspectContainer(r, UIContainer), + inspectRow(r, containers.Probe), + inspectRow(r, containers.UI), } var sb strings.Builder diff --git a/l2rctl/internal/stop/stop.go b/l2rctl/internal/stop/stop.go index b5b26ba..8346d72 100644 --- a/l2rctl/internal/stop/stop.go +++ b/l2rctl/internal/stop/stop.go @@ -4,38 +4,16 @@ import ( "fmt" "strings" + "github.com/msune/l2radar/l2rctl/internal/containers" "github.com/msune/l2radar/l2rctl/internal/docker" ) -const ( - ProbeContainer = "l2radar" - UIContainer = "l2radar-ui" -) - -var validTargets = map[string]bool{ - "all": true, - "probe": true, - "ui": true, -} - // Opts holds options for the Stop function. type Opts struct { Target string VolumeName string } -// ParseTarget extracts the target from args (default: "all"). -func ParseTarget(args []string) (string, error) { - if len(args) == 0 { - return "all", nil - } - t := args[0] - if !validTargets[t] { - return "", fmt.Errorf("invalid target: %s (must be all, probe, or ui)", t) - } - return t, nil -} - // isNotFound returns true if the error indicates a resource was not found. func isNotFound(err error) bool { if err == nil { @@ -77,14 +55,14 @@ func stopContainer(r docker.Runner, name string) error { func Stop(r docker.Runner, opts Opts) error { switch opts.Target { case "probe": - return stopContainer(r, ProbeContainer) + return stopContainer(r, containers.Probe) case "ui": - return stopContainer(r, UIContainer) + return stopContainer(r, containers.UI) case "all": - if err := stopContainer(r, ProbeContainer); err != nil { + if err := stopContainer(r, containers.Probe); err != nil { return err } - if err := stopContainer(r, UIContainer); err != nil { + if err := stopContainer(r, containers.UI); err != nil { return err } return removeVolume(r, opts.VolumeName) diff --git a/l2rctl/internal/stop/stop_test.go b/l2rctl/internal/stop/stop_test.go index d406a63..14d8b05 100644 --- a/l2rctl/internal/stop/stop_test.go +++ b/l2rctl/internal/stop/stop_test.go @@ -5,6 +5,7 @@ import ( "strings" "testing" + "github.com/msune/l2radar/l2rctl/internal/containers" "github.com/msune/l2radar/l2rctl/internal/docker" ) @@ -130,10 +131,10 @@ func TestStopAllVolumeRmAfterContainers(t *testing.T) { // Use slice matching to avoid "rm l2radar" being a substring of "volume rm l2radar-data". var probeRmIdx, uiRmIdx, volumeRmIdx int for i, c := range m.Calls { - if len(c) == 2 && c[0] == "rm" && c[1] == ProbeContainer { + if len(c) == 2 && c[0] == "rm" && c[1] == containers.Probe { probeRmIdx = i + 1 } - if len(c) == 2 && c[0] == "rm" && c[1] == UIContainer { + if len(c) == 2 && c[0] == "rm" && c[1] == containers.UI { uiRmIdx = i + 1 } if len(c) == 3 && c[0] == "volume" && c[1] == "rm" { @@ -179,7 +180,7 @@ func TestStopNotFound(t *testing.T) { } func TestStopDefaultTarget(t *testing.T) { - target, err := ParseTarget(nil) + target, err := containers.ParseTarget(nil) if err != nil { t.Fatalf("unexpected error: %v", err) } From a8426cb70914659dc24a11851b186fa63a4c7d1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Su=C3=B1=C3=A9?= Date: Sat, 28 Feb 2026 23:40:27 +0100 Subject: [PATCH 03/13] refactor(probe): use errors.Join in Probe.Close() Replace manual error slice formatting with errors.Join for cleaner multi-error aggregation. errors.Join returns nil when the slice is empty, preserving the same nil-on-success behavior. Co-Authored-By: Claude Opus 4.6 --- probe/pkg/loader/loader.go | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/probe/pkg/loader/loader.go b/probe/pkg/loader/loader.go index f218caa..fbaa95a 100644 --- a/probe/pkg/loader/loader.go +++ b/probe/pkg/loader/loader.go @@ -1,6 +1,7 @@ package loader import ( + "errors" "fmt" "log/slog" "net" @@ -120,10 +121,7 @@ func (p *Probe) Close() error { p.logger.Info("probe detached", "interface", p.iface) - if len(errs) > 0 { - return fmt.Errorf("close errors: %v", errs) - } - return nil + return errors.Join(errs...) } // Interface returns the name of the interface this probe is attached to. From 43d8e069c6f8f45c5c134f28c11b160bf65d4734 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Su=C3=B1=C3=A9?= Date: Sat, 28 Feb 2026 23:40:49 +0100 Subject: [PATCH 04/13] fix(l2rctl): add timeout to version check HTTP client Replace http.DefaultClient with a 5-second timeout client to prevent the CLI from hanging when the Go module proxy is unreachable. Co-Authored-By: Claude Opus 4.6 --- l2rctl/internal/version/version.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/l2rctl/internal/version/version.go b/l2rctl/internal/version/version.go index 5286af5..1e90383 100644 --- a/l2rctl/internal/version/version.go +++ b/l2rctl/internal/version/version.go @@ -25,6 +25,10 @@ const ( cacheTTL = 24 * time.Hour ) +// httpClient is the HTTP client used for version checks, with a timeout +// to avoid blocking the CLI on slow or unresponsive networks. +var httpClient = &http.Client{Timeout: 5 * time.Second} + // proxyResponse is the JSON returned by the Go module proxy /@latest endpoint. type proxyResponse struct { Version string `json:"Version"` @@ -54,7 +58,7 @@ func FetchLatest(ctx context.Context, baseURL string) (string, error) { return "", fmt.Errorf("creating request: %w", err) } - resp, err := http.DefaultClient.Do(req) + resp, err := httpClient.Do(req) if err != nil { return "", fmt.Errorf("fetching latest version: %w", err) } From ffc0f3a661542baa4c743ac937da0ce62ae350a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Su=C3=B1=C3=A9?= Date: Sat, 28 Feb 2026 23:41:11 +0100 Subject: [PATCH 05/13] refactor(probe): replace goto with labeled break Use a labeled for-loop (exportLoop) and break instead of goto for the export ticker select. The shutdown code follows naturally after the if/else block, eliminating the goto target. Co-Authored-By: Claude Opus 4.6 --- probe/cmd/l2radar/cli/root.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/probe/cmd/l2radar/cli/root.go b/probe/cmd/l2radar/cli/root.go index 8033968..66f7c05 100644 --- a/probe/cmd/l2radar/cli/root.go +++ b/probe/cmd/l2radar/cli/root.go @@ -99,10 +99,11 @@ func runRoot(cmd *cobra.Command, args []string) error { // Export immediately, then on each tick. exportAll(resolved, rootPinPath, rootExportDir, rootExportInterval, logger) + exportLoop: for { select { case <-ctx.Done(): - goto shutdown + break exportLoop case <-ticker.C: exportAll(resolved, rootPinPath, rootExportDir, rootExportInterval, logger) } @@ -112,7 +113,6 @@ func runRoot(cmd *cobra.Command, args []string) error { <-ctx.Done() } -shutdown: logger.Info("shutting down...") for _, p := range probes { if err := p.Close(); err != nil { From 1201dadda0477090ba58461b10c380c22ccda922 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Su=C3=B1=C3=A9?= Date: Sat, 28 Feb 2026 23:42:00 +0100 Subject: [PATCH 06/13] refactor(probe): rename module path from marc to msune Align the probe module path with the canonical repository owner (github.com/msune/l2radar/probe), matching l2rctl which already uses the correct path. Co-Authored-By: Claude Opus 4.6 --- probe/cmd/l2radar/cli/dump.go | 6 +++--- probe/cmd/l2radar/cli/dump_test.go | 4 ++-- probe/cmd/l2radar/cli/root.go | 6 +++--- probe/cmd/l2radar/main.go | 2 +- probe/go.mod | 2 +- probe/pkg/dump/dump.go | 2 +- probe/pkg/export/export.go | 2 +- probe/pkg/export/export_test.go | 2 +- 8 files changed, 13 insertions(+), 13 deletions(-) diff --git a/probe/cmd/l2radar/cli/dump.go b/probe/cmd/l2radar/cli/dump.go index cb72d96..43a86cc 100644 --- a/probe/cmd/l2radar/cli/dump.go +++ b/probe/cmd/l2radar/cli/dump.go @@ -5,9 +5,9 @@ import ( "fmt" "time" - "github.com/marc/l2radar/probe/pkg/dump" - "github.com/marc/l2radar/probe/pkg/export" - "github.com/marc/l2radar/probe/pkg/loader" + "github.com/msune/l2radar/probe/pkg/dump" + "github.com/msune/l2radar/probe/pkg/export" + "github.com/msune/l2radar/probe/pkg/loader" "github.com/spf13/cobra" ) diff --git a/probe/cmd/l2radar/cli/dump_test.go b/probe/cmd/l2radar/cli/dump_test.go index 1a47904..42b07ab 100644 --- a/probe/cmd/l2radar/cli/dump_test.go +++ b/probe/cmd/l2radar/cli/dump_test.go @@ -7,8 +7,8 @@ import ( "testing" "time" - "github.com/marc/l2radar/probe/pkg/dump" - "github.com/marc/l2radar/probe/pkg/export" + "github.com/msune/l2radar/probe/pkg/dump" + "github.com/msune/l2radar/probe/pkg/export" ) func TestMarshalDumpJSONIncludesInterfaceInfoAndStats(t *testing.T) { diff --git a/probe/cmd/l2radar/cli/root.go b/probe/cmd/l2radar/cli/root.go index 66f7c05..e98515c 100644 --- a/probe/cmd/l2radar/cli/root.go +++ b/probe/cmd/l2radar/cli/root.go @@ -9,9 +9,9 @@ import ( "syscall" "time" - "github.com/marc/l2radar/probe/pkg/dump" - "github.com/marc/l2radar/probe/pkg/export" - "github.com/marc/l2radar/probe/pkg/loader" + "github.com/msune/l2radar/probe/pkg/dump" + "github.com/msune/l2radar/probe/pkg/export" + "github.com/msune/l2radar/probe/pkg/loader" "github.com/spf13/cobra" ) diff --git a/probe/cmd/l2radar/main.go b/probe/cmd/l2radar/main.go index 85ae88b..e682677 100644 --- a/probe/cmd/l2radar/main.go +++ b/probe/cmd/l2radar/main.go @@ -3,7 +3,7 @@ package main import ( "os" - "github.com/marc/l2radar/probe/cmd/l2radar/cli" + "github.com/msune/l2radar/probe/cmd/l2radar/cli" ) func main() { diff --git a/probe/go.mod b/probe/go.mod index 7c11151..7865c15 100644 --- a/probe/go.mod +++ b/probe/go.mod @@ -1,4 +1,4 @@ -module github.com/marc/l2radar/probe +module github.com/msune/l2radar/probe go 1.24.4 diff --git a/probe/pkg/dump/dump.go b/probe/pkg/dump/dump.go index 7af10db..ab61a0e 100644 --- a/probe/pkg/dump/dump.go +++ b/probe/pkg/dump/dump.go @@ -9,7 +9,7 @@ import ( "sort" "strings" - "github.com/marc/l2radar/probe/pkg/oui" + "github.com/msune/l2radar/probe/pkg/oui" "text/tabwriter" "time" diff --git a/probe/pkg/export/export.go b/probe/pkg/export/export.go index cc8fb4a..e018e44 100644 --- a/probe/pkg/export/export.go +++ b/probe/pkg/export/export.go @@ -10,7 +10,7 @@ import ( "strings" "time" - "github.com/marc/l2radar/probe/pkg/dump" + "github.com/msune/l2radar/probe/pkg/dump" ) // InterfaceInfo holds the monitored interface's own addresses. diff --git a/probe/pkg/export/export_test.go b/probe/pkg/export/export_test.go index 9b55b90..c0c2da8 100644 --- a/probe/pkg/export/export_test.go +++ b/probe/pkg/export/export_test.go @@ -8,7 +8,7 @@ import ( "testing" "time" - "github.com/marc/l2radar/probe/pkg/dump" + "github.com/msune/l2radar/probe/pkg/dump" ) func TestInterfaceDataJSON(t *testing.T) { From e5f7681924402afae393bc0d089e607ab0d924c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Su=C3=B1=C3=A9?= Date: Sat, 28 Feb 2026 23:42:23 +0100 Subject: [PATCH 07/13] refactor(probe/bpf): document track_mac rationale; remove extra blank line Add a comment explaining why track_mac(src_mac) is called before handle_arp in the ETH_P_ARP case: handle_arp may bail on validation, so tracking the Ethernet source MAC here ensures it is always recorded. Also remove an extraneous blank line after the VLAN block. Co-Authored-By: Claude Opus 4.6 --- probe/bpf/l2radar.c | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/probe/bpf/l2radar.c b/probe/bpf/l2radar.c index 08045c0..8ecfd62 100644 --- a/probe/bpf/l2radar.c +++ b/probe/bpf/l2radar.c @@ -377,11 +377,16 @@ int l2radar(struct __sk_buff *skb) l3_offset += 4; } - void *l3_start = data + l3_offset; switch (eth_proto) { case ETH_P_ARP: + /* + * Track the Ethernet source MAC here, before handle_arp, + * because handle_arp may bail on ARP header validation + * (non-Ethernet, non-IPv4). This ensures the L2 sender + * is always recorded even for malformed ARP payloads. + */ track_mac(src_mac); /* From 2f30124449a771a3a931d6be235ec16db5e512e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Su=C3=B1=C3=A9?= Date: Sat, 28 Feb 2026 23:43:16 +0100 Subject: [PATCH 08/13] refactor(ui): extract shared renderMasked to lib Move the duplicated renderMasked function from NeighbourTable and InterfaceInfo into ui/src/lib/renderMasked.jsx for reuse. Co-Authored-By: Claude Opus 4.6 --- ui/src/components/InterfaceInfo.jsx | 7 +------ ui/src/components/NeighbourTable.jsx | 7 +------ ui/src/lib/renderMasked.jsx | 5 +++++ 3 files changed, 7 insertions(+), 12 deletions(-) create mode 100644 ui/src/lib/renderMasked.jsx diff --git a/ui/src/components/InterfaceInfo.jsx b/ui/src/components/InterfaceInfo.jsx index f1d0878..591283c 100644 --- a/ui/src/components/InterfaceInfo.jsx +++ b/ui/src/components/InterfaceInfo.jsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react' import { formatAgo } from '../lib/timeago' import { splitMacForDisplay, splitIPv6ForDisplay } from '../lib/macObfuscation' +import { renderMasked } from '../lib/renderMasked' function parseDuration(s) { if (!s) return 0 @@ -20,12 +21,6 @@ function formatBytes(bytes) { return `${Number.isInteger(val) ? val : val.toFixed(1)} ${units[i]}` } -function renderMasked(text, splitFn) { - const { prefix, masked } = splitFn(text) - if (!masked) return text - return <>{prefix}{masked} -} - function InterfaceInfo({ name, timestamp, info, privacyMode = false }) { const [now, setNow] = useState(Date.now()) const [statsOpen, setStatsOpen] = useState(false) diff --git a/ui/src/components/NeighbourTable.jsx b/ui/src/components/NeighbourTable.jsx index 8b9be9d..8a97a8c 100644 --- a/ui/src/components/NeighbourTable.jsx +++ b/ui/src/components/NeighbourTable.jsx @@ -3,6 +3,7 @@ import { formatAgo } from '../lib/timeago' import { sortNeighbours } from '../lib/sorting' import { lookupOUI } from '../lib/ouiLookup' import { splitMacForDisplay, splitIPv6ForDisplay } from '../lib/macObfuscation' +import { renderMasked } from '../lib/renderMasked' const COLUMNS = [ { key: 'interface', label: 'Interface' }, @@ -26,12 +27,6 @@ function rowKey(n) { return `${n.interface}-${n.mac}` } -function renderMasked(text, splitFn) { - const { prefix, masked } = splitFn(text) - if (!masked) return text - return <>{prefix}{masked} -} - function NeighbourTable({ neighbours, showInterface = true, privacyMode = false }) { const columns = showInterface ? COLUMNS diff --git a/ui/src/lib/renderMasked.jsx b/ui/src/lib/renderMasked.jsx new file mode 100644 index 0000000..cad88ac --- /dev/null +++ b/ui/src/lib/renderMasked.jsx @@ -0,0 +1,5 @@ +export function renderMasked(text, splitFn) { + const { prefix, masked } = splitFn(text) + if (!masked) return text + return <>{prefix}{masked} +} From 1e42156bb1d8fd00ccddbe1e87bd747936c5ae1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Su=C3=B1=C3=A9?= Date: Sat, 28 Feb 2026 23:44:03 +0100 Subject: [PATCH 09/13] perf(ui): cache lookupOUI result per table row Call lookupOUI once per row and reuse the result instead of calling it twice (condition check + display). Applies to both desktop table and mobile card views. Co-Authored-By: Claude Opus 4.6 --- ui/src/components/NeighbourTable.jsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/ui/src/components/NeighbourTable.jsx b/ui/src/components/NeighbourTable.jsx index 8a97a8c..1318eaa 100644 --- a/ui/src/components/NeighbourTable.jsx +++ b/ui/src/components/NeighbourTable.jsx @@ -108,6 +108,7 @@ function NeighbourTable({ neighbours, showInterface = true, privacyMode = false const k = rowKey(n) const stale = isStale(n) const fresh = freshKeys.has(k) + const vendor = lookupOUI(n.mac) return ( {privacyMode ? renderMasked(n.mac, splitMacForDisplay) : n.mac} - {lookupOUI(n.mac) && ( + {vendor && ( - ({lookupOUI(n.mac)}) + ({vendor}) )} @@ -162,6 +163,7 @@ function NeighbourTable({ neighbours, showInterface = true, privacyMode = false const k = rowKey(n) const stale = isStale(n) const fresh = freshKeys.has(k) + const vendor = lookupOUI(n.mac) return (
{privacyMode ? renderMasked(n.mac, splitMacForDisplay) : n.mac} - {lookupOUI(n.mac) && ( + {vendor && (
- {lookupOUI(n.mac)} + {vendor}
)}
From b930c7cb4dba6d2a52a9bbfc0225c3ffa08b5f5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Su=C3=B1=C3=A9?= Date: Sat, 28 Feb 2026 23:44:47 +0100 Subject: [PATCH 10/13] fix(ui): sort IPv4 numerically; avoid Date allocs in timestamp sort Replace lexicographic IPv4 sort with numeric comparison via ipv4ToNum helper (using unsigned shift to handle high octets). Replace Date constructor calls in timestamp sorting with localeCompare, which works correctly for RFC3339/UTC timestamps. Add tests for numeric IPv4 ordering. Co-Authored-By: Claude Opus 4.6 --- ui/src/lib/sorting.js | 16 +++++++++++++--- ui/src/lib/sorting.test.js | 20 ++++++++++++++++++++ 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/ui/src/lib/sorting.js b/ui/src/lib/sorting.js index 5f6eda1..04c8304 100644 --- a/ui/src/lib/sorting.js +++ b/ui/src/lib/sorting.js @@ -1,3 +1,13 @@ +/** + * Convert an IPv4 address string to a numeric value for comparison. + * Returns -1 for missing/invalid addresses so they sort first. + */ +function ipv4ToNum(ip) { + if (!ip) return -1 + const parts = ip.split('.') + return ((parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]) >>> 0 +} + /** * Sort neighbours by a given column. * Returns a new sorted array. @@ -14,17 +24,17 @@ export function sortNeighbours(neighbours, sortKey, sortDir) { cmp = a.mac.localeCompare(b.mac) break case 'ipv4': - cmp = (a.ipv4[0] || '').localeCompare(b.ipv4[0] || '') + cmp = ipv4ToNum(a.ipv4[0]) - ipv4ToNum(b.ipv4[0]) break case 'ipv6': cmp = (a.ipv6[0] || '').localeCompare(b.ipv6[0] || '') break case 'firstSeen': - cmp = new Date(a.firstSeen) - new Date(b.firstSeen) + cmp = (a.firstSeen || '').localeCompare(b.firstSeen || '') break case 'lastSeen': default: - cmp = new Date(a.lastSeen) - new Date(b.lastSeen) + cmp = (a.lastSeen || '').localeCompare(b.lastSeen || '') break } diff --git a/ui/src/lib/sorting.test.js b/ui/src/lib/sorting.test.js index da9c05c..f1afa19 100644 --- a/ui/src/lib/sorting.test.js +++ b/ui/src/lib/sorting.test.js @@ -76,4 +76,24 @@ describe('sortNeighbours', () => { it('handles empty array', () => { expect(sortNeighbours([], 'mac', 'asc')).toEqual([]) }) + + it('sorts IPv4 numerically (192.168.1.9 before 192.168.1.10)', () => { + const rows = [ + { interface: 'eth0', mac: 'a', ipv4: ['192.168.1.10'], ipv6: [], firstSeen: '', lastSeen: '' }, + { interface: 'eth0', mac: 'b', ipv4: ['192.168.1.9'], ipv6: [], firstSeen: '', lastSeen: '' }, + ] + const sorted = sortNeighbours(rows, 'ipv4', 'asc') + expect(sorted[0].ipv4[0]).toBe('192.168.1.9') + expect(sorted[1].ipv4[0]).toBe('192.168.1.10') + }) + + it('sorts high-octet IPv4 addresses correctly', () => { + const rows = [ + { interface: 'eth0', mac: 'a', ipv4: ['192.168.1.1'], ipv6: [], firstSeen: '', lastSeen: '' }, + { interface: 'eth0', mac: 'b', ipv4: ['10.0.0.1'], ipv6: [], firstSeen: '', lastSeen: '' }, + ] + const sorted = sortNeighbours(rows, 'ipv4', 'asc') + expect(sorted[0].ipv4[0]).toBe('10.0.0.1') + expect(sorted[1].ipv4[0]).toBe('192.168.1.1') + }) }) From 121b8a05eaa08adab10c277dfca42202fe08310e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Su=C3=B1=C3=A9?= Date: Sat, 28 Feb 2026 23:45:30 +0100 Subject: [PATCH 11/13] fix(ui): eliminate redundant double-fetch in useNeighbourData Replace the two-pass fetch strategy (If-Modified-Since check, then unconditional re-fetch of all files) with a single-pass approach using a per-file parsed data cache. Files that return 304 are served from cache; only changed files are re-parsed. The cache is pruned when files disappear from the directory listing (deleted interfaces). Track first-load via a dedicated ref instead of depending on loading state. Co-Authored-By: Claude Opus 4.6 --- ui/src/hooks/useNeighbourData.js | 43 +++++++++++++++++--------------- 1 file changed, 23 insertions(+), 20 deletions(-) diff --git a/ui/src/hooks/useNeighbourData.js b/ui/src/hooks/useNeighbourData.js index ee7e07b..7639bd4 100644 --- a/ui/src/hooks/useNeighbourData.js +++ b/ui/src/hooks/useNeighbourData.js @@ -7,6 +7,7 @@ const DEFAULT_POLL_INTERVAL = 5000 /** * Hook that discovers and polls JSON files from the data endpoint. * Uses If-Modified-Since to avoid re-downloading unchanged files. + * Maintains a per-file parsed cache so only changed files are fetched. */ export function useNeighbourData(pollInterval = DEFAULT_POLL_INTERVAL) { const [neighbours, setNeighbours] = useState([]) @@ -15,6 +16,8 @@ export function useNeighbourData(pollInterval = DEFAULT_POLL_INTERVAL) { const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const lastModifiedRef = useRef({}) + const parsedCacheRef = useRef({}) + const firstLoadRef = useRef(true) const fetchData = useCallback(async () => { try { @@ -30,16 +33,26 @@ export function useNeighbourData(pollInterval = DEFAULT_POLL_INTERVAL) { .map((entry) => entry.name) if (jsonFiles.length === 0) { + parsedCacheRef.current = {} + lastModifiedRef.current = {} setNeighbours([]) setTimestamps({}) setInterfaceInfo({}) setLoading(false) + firstLoadRef.current = false return } - // Fetch each JSON file with If-Modified-Since - const dataArray = [] - const newLastModified = { ...lastModifiedRef.current } + // Prune cache: remove entries for files no longer in the listing + const fileSet = new Set(jsonFiles) + for (const key of Object.keys(parsedCacheRef.current)) { + if (!fileSet.has(key)) { + delete parsedCacheRef.current[key] + delete lastModifiedRef.current[key] + } + } + + // Single fetch pass with If-Modified-Since let anyUpdated = false for (const file of jsonFiles) { @@ -52,7 +65,7 @@ export function useNeighbourData(pollInterval = DEFAULT_POLL_INTERVAL) { const resp = await fetch(url, { headers }) if (resp.status === 304) { - // Not modified — use cached data (skip) + // Not modified — cache already has data continue } @@ -63,39 +76,29 @@ export function useNeighbourData(pollInterval = DEFAULT_POLL_INTERVAL) { const lastMod = resp.headers.get('Last-Modified') if (lastMod) { - newLastModified[file] = lastMod + lastModifiedRef.current[file] = lastMod } - const data = await resp.json() - dataArray.push(data) + parsedCacheRef.current[file] = await resp.json() anyUpdated = true } - lastModifiedRef.current = newLastModified - - if (anyUpdated || loading) { - // Re-fetch all files to get complete picture when any file changed - const allData = [] - for (const file of jsonFiles) { - const url = `${DATA_BASE_URL}${file}` - const resp = await fetch(url) - if (resp.ok) { - allData.push(await resp.json()) - } - } + if (anyUpdated || firstLoadRef.current) { + const allData = Object.values(parsedCacheRef.current) const merged = mergeNeighbours(allData) setNeighbours(merged.neighbours) setTimestamps(merged.timestamps) setInterfaceInfo(merged.interfaceInfo) } + firstLoadRef.current = false setError(null) } catch (err) { setError(err.message) } finally { setLoading(false) } - }, [loading]) + }, []) useEffect(() => { fetchData() From ae1220573ccafe2c1c787e0b809b4f17b6fb7c9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Su=C3=B1=C3=A9?= Date: Sat, 28 Feb 2026 23:46:16 +0100 Subject: [PATCH 12/13] docs: update CHANGELOG with refactoring commits Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 31d7a1e..131578d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,17 @@ All notable changes to this project are documented in this file. ## Next +- [`121b8a0`](https://github.com/msune/l2radar/commit/121b8a0) fix(ui): eliminate redundant double-fetch in useNeighbourData +- [`b930c7c`](https://github.com/msune/l2radar/commit/b930c7c) fix(ui): sort IPv4 numerically; avoid Date allocs in timestamp sort +- [`1e42156`](https://github.com/msune/l2radar/commit/1e42156) perf(ui): cache lookupOUI result per table row +- [`2f30124`](https://github.com/msune/l2radar/commit/2f30124) refactor(ui): extract shared renderMasked to lib +- [`e5f7681`](https://github.com/msune/l2radar/commit/e5f7681) refactor(probe/bpf): document track_mac rationale; remove extra blank line +- [`1201dad`](https://github.com/msune/l2radar/commit/1201dad) refactor(probe): rename module path from marc to msune +- [`ffc0f3a`](https://github.com/msune/l2radar/commit/ffc0f3a) refactor(probe): replace goto with labeled break +- [`a8426cb`](https://github.com/msune/l2radar/commit/a8426cb) refactor(probe): use errors.Join in Probe.Close() +- [`43d8e06`](https://github.com/msune/l2radar/commit/43d8e06) fix(l2rctl): add timeout to version check HTTP client +- [`2a5eec8`](https://github.com/msune/l2radar/commit/2a5eec8) refactor(l2rctl): extract shared containers package +- [`df53b00`](https://github.com/msune/l2radar/commit/df53b00) fix(ui): correct http-plain.conf data alias path - demo: automated GIF recording in CI (on version tag push); deterministic 15-host simulation across eth0 and wlan0; GIF uploaded as a GitHub release asset (fixes [#3](https://github.com/msune/l2radar/issues/3)) - [`0441c9d`](https://github.com/msune/l2radar/commit/0441c9d) l2rctl: add --privacy-mode flag for UI container (red → green) - [`669e588`](https://github.com/msune/l2radar/commit/669e588) ui: generate config.json in entrypoint.sh for --privacy-mode From f1defa799f2ed6aa8686e9cd118539e2a4b7bb90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Su=C3=B1=C3=A9?= Date: Sat, 28 Feb 2026 23:57:26 +0100 Subject: [PATCH 13/13] CHANGELOG: prepare for v0.1.3 Prepare CHANGELOG.md for v0.1.3 Add missing v0.1.2 section (privacy mode + demo GIF recording) with correct post-merge commit hashes. Move refactoring entries to v0.1.3. Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 131578d..cdd3ed7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ All notable changes to this project are documented in this file. -## Next +## v0.1.3 (2026-02-28) - [`121b8a0`](https://github.com/msune/l2radar/commit/121b8a0) fix(ui): eliminate redundant double-fetch in useNeighbourData - [`b930c7c`](https://github.com/msune/l2radar/commit/b930c7c) fix(ui): sort IPv4 numerically; avoid Date allocs in timestamp sort - [`1e42156`](https://github.com/msune/l2radar/commit/1e42156) perf(ui): cache lookupOUI result per table row @@ -14,13 +14,16 @@ All notable changes to this project are documented in this file. - [`43d8e06`](https://github.com/msune/l2radar/commit/43d8e06) fix(l2rctl): add timeout to version check HTTP client - [`2a5eec8`](https://github.com/msune/l2radar/commit/2a5eec8) refactor(l2rctl): extract shared containers package - [`df53b00`](https://github.com/msune/l2radar/commit/df53b00) fix(ui): correct http-plain.conf data alias path + +## v0.1.2 (2026-02-28) - demo: automated GIF recording in CI (on version tag push); deterministic 15-host simulation across eth0 and wlan0; GIF uploaded as a GitHub release asset (fixes [#3](https://github.com/msune/l2radar/issues/3)) -- [`0441c9d`](https://github.com/msune/l2radar/commit/0441c9d) l2rctl: add --privacy-mode flag for UI container (red → green) -- [`669e588`](https://github.com/msune/l2radar/commit/669e588) ui: generate config.json in entrypoint.sh for --privacy-mode -- [`b0da078`](https://github.com/msune/l2radar/commit/b0da078) ui: wire privacy mode into App -- [`1f4738d`](https://github.com/msune/l2radar/commit/1f4738d) ui: add PrivacyToggle component and tests (red → green) -- [`249c5f4`](https://github.com/msune/l2radar/commit/249c5f4) ui: add useConfig hook and tests (red → green) -- [`2e4fe3c`](https://github.com/msune/l2radar/commit/2e4fe3c) ui: add MAC obfuscation library and tests (red → green) +- [`bf3c154`](https://github.com/msune/l2radar/commit/bf3c154) ui: gray out obfuscated MAC/IPv6 bytes in privacy mode +- [`5c4388f`](https://github.com/msune/l2radar/commit/5c4388f) l2rctl: add --privacy-mode flag for UI container (red → green) +- [`9f7ae62`](https://github.com/msune/l2radar/commit/9f7ae62) ui: generate config.json in entrypoint.sh for --privacy-mode +- [`22bc275`](https://github.com/msune/l2radar/commit/22bc275) ui: wire privacy mode into App +- [`5b1c966`](https://github.com/msune/l2radar/commit/5b1c966) ui: add PrivacyToggle component and tests (red → green) +- [`a7356c3`](https://github.com/msune/l2radar/commit/a7356c3) ui: add useConfig hook and tests (red → green) +- [`6d60694`](https://github.com/msune/l2radar/commit/6d60694) ui: add MAC obfuscation library and tests (red → green) ## v0.1.1 (2026-02-20) - [`45ff683`](https://github.com/msune/l2radar/commit/45ff683) docs: add changelog