Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 21 additions & 7 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,28 @@

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
- [`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

## 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
Expand Down
3 changes: 2 additions & 1 deletion l2rctl/cmd/l2rctl/cli/start.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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
}
Expand Down
3 changes: 2 additions & 1 deletion l2rctl/cmd/l2rctl/cli/stop.go
Original file line number Diff line number Diff line change
@@ -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"
)
Expand All @@ -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
}
Expand Down
70 changes: 70 additions & 0 deletions l2rctl/internal/containers/containers.go
Original file line number Diff line number Diff line change
@@ -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,
}
}
101 changes: 101 additions & 0 deletions l2rctl/internal/containers/containers_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
5 changes: 2 additions & 3 deletions l2rctl/internal/dump/dump.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
}
Expand Down
5 changes: 3 additions & 2 deletions l2rctl/internal/start/probe.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package start
import (
"fmt"

"github.com/msune/l2radar/l2rctl/internal/containers"
"github.com/msune/l2radar/l2rctl/internal/docker"
)

Expand All @@ -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 {
Expand All @@ -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 != "" {
Expand Down
69 changes: 8 additions & 61 deletions l2rctl/internal/start/start.go
Original file line number Diff line number Diff line change
@@ -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)
}
Expand Down Expand Up @@ -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
}
}
Expand Down
Loading