diff --git a/internal/api/lldp_capability.go b/internal/api/lldp_capability.go index e9724dc..5b6acae 100644 --- a/internal/api/lldp_capability.go +++ b/internal/api/lldp_capability.go @@ -34,6 +34,20 @@ type LLDPCapabilityResult struct { Detail string `json:"detail,omitempty"` } +func normalizedLLDPCapabilityResult(result *LLDPCapabilityResult) *LLDPCapabilityResult { + if result == nil { + return nil + } + normalized := *result + if normalized.RequestedPackages == nil { + normalized.RequestedPackages = []string{} + } + if normalized.AddedPackages == nil { + normalized.AddedPackages = []string{} + } + return &normalized +} + func (s *Server) handleLLDPStatus(w http.ResponseWriter, r *http.Request) { id, ok := pathID(w, r, "id") if !ok { @@ -72,7 +86,7 @@ func (s *Server) handleLLDPStatus(w http.ResponseWriter, r *http.Request) { } } } - writeJSON(w, http.StatusOK, out) + writeJSON(w, http.StatusOK, normalizedLLDPCapabilityResult(&out)) } func (s *Server) handleLLDPCapability(w http.ResponseWriter, r *http.Request) { @@ -147,5 +161,5 @@ func (s *Server) handleLLDPCapability(w http.ResponseWriter, r *http.Request) { writeErr(w, http.StatusBadGateway, err.Error()) return } - writeJSON(w, http.StatusOK, res) + writeJSON(w, http.StatusOK, normalizedLLDPCapabilityResult(res)) } diff --git a/internal/api/lldp_capability_test.go b/internal/api/lldp_capability_test.go new file mode 100644 index 0000000..dc174b0 --- /dev/null +++ b/internal/api/lldp_capability_test.go @@ -0,0 +1,87 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "strconv" + "testing" +) + +func TestLLDPStatusNormalizesPackageArrays(t *testing.T) { + for _, tc := range []struct { + name string + requested []string + added []string + wantReq string + wantAdded string + }{ + {name: "nil", wantReq: "[]", wantAdded: "[]"}, + { + name: "nonempty", requested: []string{"lldpd"}, added: []string{"lldpd", "libcap"}, + wantReq: `["lldpd"]`, wantAdded: `["lldpd","libcap"]`, + }, + } { + t.Run(tc.name, func(t *testing.T) { + h := newHarness(t) + h.setup() + dev := h.seedDevice("lldp-"+tc.name, true, nil) + ctx := context.Background() + if err := h.db.BeginCapabilityInstall(ctx, dev.ID, "lldp", "apk", tc.requested, []string{}, nil); err != nil { + t.Fatal(err) + } + if err := h.db.CompleteCapabilityInstall(ctx, dev.ID, "lldp", tc.added, nil); err != nil { + t.Fatal(err) + } + + w := h.do(http.MethodGet, "/api/v1/devices/"+strconv.FormatInt(dev.ID, 10)+"/capabilities/lldp", nil) + assertLLDPPackageJSON(t, w.Code, w.Body.Bytes(), tc.wantReq, tc.wantAdded) + }) + } +} + +func TestLLDPActionNormalizesPackageArrays(t *testing.T) { + for _, tc := range []struct { + name string + result *LLDPCapabilityResult + wantReq string + wantAdded string + }{ + { + name: "nil", result: &LLDPCapabilityResult{DeviceID: 7, State: "installed"}, + wantReq: "[]", wantAdded: "[]", + }, + { + name: "nonempty", result: &LLDPCapabilityResult{ + DeviceID: 7, State: "installed", RequestedPackages: []string{"lldpd"}, AddedPackages: []string{"lldpd"}, + }, + wantReq: `["lldpd"]`, wantAdded: `["lldpd"]`, + }, + } { + t.Run(tc.name, func(t *testing.T) { + h, enroller := harnessWithEnroller(t) + enroller.lldpRes = tc.result + w := h.do(http.MethodPost, "/api/v1/devices/7/capabilities/lldp", map[string]any{ + "action": "diagnose", "username": "root", "acknowledge_read_only_diagnostics": true, + }) + assertLLDPPackageJSON(t, w.Code, w.Body.Bytes(), tc.wantReq, tc.wantAdded) + }) + } +} + +func assertLLDPPackageJSON(t *testing.T, status int, body []byte, wantRequested, wantAdded string) { + t.Helper() + if status != http.StatusOK { + t.Fatalf("status=%d body=%s", status, body) + } + var got map[string]json.RawMessage + if err := json.Unmarshal(body, &got); err != nil { + t.Fatalf("decode response: %v: %s", err, body) + } + if string(got["requested_packages"]) != wantRequested || string(got["added_packages"]) != wantAdded { + t.Fatalf("requested_packages=%s added_packages=%s body=%s", got["requested_packages"], got["added_packages"], body) + } + if _, ok := got["configured_interfaces"]; ok { + t.Fatalf("configured_interfaces should remain omitted: %s", body) + } +} diff --git a/ui/src/lib/api.ts b/ui/src/lib/api.ts index 1bd1854..5bf898b 100644 --- a/ui/src/lib/api.ts +++ b/ui/src/lib/api.ts @@ -714,7 +714,8 @@ export interface LLDPCapabilityResult { state: 'not_installed' | 'install_planned' | 'installing' | 'installed' | 'configure_planned' | 'remove_planned' | 'removing' | 'error' package_manager?: 'apk' | 'opkg' requested_packages: string[] - added_packages: string[] + // Older controllers may return null or omit this field; current responses always use an array. + added_packages?: string[] | null plan?: string plan_hash?: string diagnostics?: string diff --git a/ui/src/screens/Devices.tsx b/ui/src/screens/Devices.tsx index 5be2ada..b8cf998 100644 --- a/ui/src/screens/Devices.tsx +++ b/ui/src/screens/Devices.tsx @@ -1513,7 +1513,7 @@ function LLDPCapability({ deviceID, managementMode, managementModeError, onUpdat {installed && (
Controller record: {status?.state}. Controller-added packages:{' '} - {status?.added_packages.join(', ') || 'none; lldpd existed before adoption'}. + {(status?.added_packages ?? []).join(', ') || 'none recorded'}.
)} {!installed && !readOnly && ( diff --git a/ui/src/screens/screens.test.tsx b/ui/src/screens/screens.test.tsx index f7088ac..51d2011 100644 --- a/ui/src/screens/screens.test.tsx +++ b/ui/src/screens/screens.test.tsx @@ -2661,6 +2661,45 @@ describe('Devices — re-probe panel', () => { expect(api.changeLLDPCapability).not.toHaveBeenCalled() }) + it.each([ + ['null', null, 'none recorded'], + ['absent', undefined, 'none recorded'], + ['empty', [], 'none recorded'], + ['nonempty', ['lldpd', 'libcap'], 'lldpd, libcap'], + ])('renders legacy %s LLDP added packages while details are collapsed', async (_shape, addedPackages, want) => { + const response: Record