Skip to content
Merged
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
5 changes: 5 additions & 0 deletions cmd/multivendor.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ package cmd

import (
"fmt"
"time"

"github.com/spf13/viper"

Expand Down Expand Up @@ -148,6 +149,10 @@ func InitializeMultiVendor() error {

logging.Debugf("Multi-vendor cache directory: %s", cacheDir)
cacheManager = vendors.NewCacheManager(cacheDir, apiRegistry)
cacheManager.SetRefreshTuning(
viper.GetInt("api.refresh_concurrency"),
time.Duration(viper.GetInt("api.refresh_timeout"))*time.Second,
)

if err := cacheManager.Initialize(); err != nil {
return fmt.Errorf("failed to initialize cache manager: %w", err)
Expand Down
20 changes: 18 additions & 2 deletions cmd/refresh.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"github.com/ravinald/wifimgr/internal/cmdutils"
"github.com/ravinald/wifimgr/internal/config"
"github.com/ravinald/wifimgr/internal/logging"
"github.com/ravinald/wifimgr/internal/refreshui"
"github.com/ravinald/wifimgr/internal/vendors"
)

Expand Down Expand Up @@ -148,12 +149,27 @@ func runRefreshAllSites(scope string) error {
fmt.Printf("Successfully refreshed %s\n", apiFlag)
} else {
fmt.Printf("Refreshing cache for %d APIs...\n", len(targetAPIs))

// On a terminal, drive the live status board and hold log output (incl.
// the index-rebuild MAC-collision warnings) until the board tears down so
// it doesn't paint over the render. Piped/redirected output keeps the
// linear text and logs unbuffered.
interactive := refreshui.Interactive()
reporter, stopBoard := refreshui.New(targetAPIs, interactive)
release := func() {}
if interactive {
release = logging.PauseOutput()
}

var errs map[string]error
if managed == nil {
errs = cacheMgr.RefreshAllAPIs(ctx)
errs = cacheMgr.RefreshAllAPIs(ctx, reporter)
} else {
errs = cacheMgr.RefreshAllAPIsManaged(ctx, managed)
errs = cacheMgr.RefreshAllAPIsManaged(ctx, managed, reporter)
}
stopBoard()
release()

successCount := len(targetAPIs) - len(errs)
fmt.Printf("\nRefreshed %d/%d APIs successfully\n", successCount, len(targetAPIs))
if len(errs) > 0 {
Expand Down
3 changes: 2 additions & 1 deletion docs/config-minimal-sample.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@
"url": "https://api.mist.com/api/v1",
"credentials": {
"org_id": "your-org-uuid-here"
}
},
"sync_type": ["ap"]
}
}
}
21 changes: 20 additions & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -456,7 +456,8 @@ The main configuration file (`~/.config/wifimgr/wifimgr-config.json`) supports t
"rate_limit": 5000,
"results_limit": 100,
"cache_ttl": 86400,
"connection_timeout": 5
"connection_timeout": 5,
"sync_type": ["ap", "switch", "gateway"]
}
},
"display": {
Expand Down Expand Up @@ -502,6 +503,24 @@ instead of hanging until the request timeout.
A dead host now surfaces as `unhealthy` / `connection failure` in `show api status` within
`connection_timeout` seconds rather than ~30s.

### Sync Type

`sync_type` is a per-API list declaring which device types a refresh collects:
any of `ap`, `switch`, `gateway`. Site attributes (name, address, timezone)
always sync.

- **Omitted or `[]`:** site attributes only — no device inventory, configs,
statuses, or BSSIDs.
- **`["ap"]`:** APs only, with their statuses and BSSIDs.
- **`["ap", "switch", "gateway"]`:** all device types.

Statuses are fetched only when at least one device type is listed; BSSIDs only
when `ap` is listed. Org-level templates, profiles, and WLANs always sync.

> **Upgrade note:** earlier versions collected all three device types
> unconditionally. An API without `sync_type` now syncs site attributes only —
> add `sync_type` to keep collecting devices.

### Accessing Configuration Values

**Direct Viper Access:**
Expand Down
27 changes: 25 additions & 2 deletions docs/multi-vendor/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ API connections are defined with user-chosen labels in the main config file:
},
"rate_limit": 5000,
"results_limit": 100,
"cache_ttl": 86400
"cache_ttl": 86400,
"sync_type": ["ap", "switch", "gateway"]
},
"mist-lab": {
"vendor": "mist",
Expand All @@ -31,7 +32,8 @@ API connections are defined with user-chosen labels in the main config file:
"org_id": "xyz-789-uvw",
"api_key": "..."
},
"cache_ttl": 0
"cache_ttl": 0,
"sync_type": ["ap"]
},
"meraki-corp": {
"vendor": "meraki",
Expand Down Expand Up @@ -75,6 +77,27 @@ API connections are defined with user-chosen labels in the main config file:
| `rate_limit` | No | Requests per minute (vendor default if omitted) |
| `results_limit` | No | Max results per API call |
| `cache_ttl` | No | Cache TTL in seconds (see below) |
| `sync_type` | No | Device types to collect: any of `ap`, `switch`, `gateway` (see below) |

### Sync Type Configuration

`sync_type` declares which device types a refresh collects for this API. It's a
list drawn from `ap`, `switch`, and `gateway`. Site attributes (name, address,
timezone) always sync regardless.

| Value | Behavior |
|-------|----------|
| Omitted | **Site attributes only — no devices.** |
| `[]` | Same as omitted (sites only) |
| `["ap"]` | Collects APs only (plus AP statuses and BSSIDs) |
| `["ap", "switch", "gateway"]` | Collects all device types |

Device statuses are fetched only when at least one device type is listed; BSSIDs
only when `ap` is listed. Org-level templates, profiles, and WLANs always sync.

> **Upgrade note:** earlier versions collected all three device types
> unconditionally. An API without `sync_type` now syncs **site attributes only**.
> Add `sync_type` to keep collecting devices.

### Cache TTL Configuration

Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ require (
require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect
github.com/charmbracelet/harmonica v0.2.0 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlv
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
Expand Down
60 changes: 60 additions & 0 deletions internal/config/api_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,9 @@ func BuildAPIConfigsFromViper() (map[string]*vendors.APIConfig, []ValidationWarn
}
}

syncTypes, syncWarnings := parseSyncTypes(label, nested)
warnings = append(warnings, syncWarnings...)

config := &vendors.APIConfig{
Label: label,
Vendor: vendor,
Expand All @@ -112,6 +115,7 @@ func BuildAPIConfigsFromViper() (map[string]*vendors.APIConfig, []ValidationWarn
ResultsLimit: getIntFromMap(nested, "results_limit"),
CacheTTL: getCacheTTLFromMap(nested),
ConnectTimeout: resolveConnectTimeout(nested),
SyncTypes: syncTypes,
}

// Apply vendor-specific defaults
Expand Down Expand Up @@ -353,6 +357,62 @@ func getStringFromMap(m map[string]interface{}, key string) string {
return ""
}

// validSyncTypes is the set of device types an API may declare under sync_type.
var validSyncTypes = map[string]bool{"ap": true, "switch": true, "gateway": true}

// parseSyncTypes reads the optional sync_type list for an API, normalizing each
// entry to lowercase, dropping blanks and duplicates. Unknown entries are
// skipped with a warning rather than failing the load. A missing key returns a
// nil slice, which the refresh path treats as "sites only".
func parseSyncTypes(label string, nested map[string]interface{}) ([]string, []ValidationWarning) {
raw, ok := nested["sync_type"]
if !ok {
return nil, nil
}

// JSON/YAML decode lists as []interface{}; tolerate []string too.
var items []interface{}
switch v := raw.(type) {
case []interface{}:
items = v
case []string:
for _, s := range v {
items = append(items, s)
}
default:
return nil, []ValidationWarning{{
Level: "api",
API: label,
Message: fmt.Sprintf("API %q has invalid 'sync_type' (expected a list of 'ap', 'switch', 'gateway')", label),
}}
}

var result []string
var warnings []ValidationWarning
seen := make(map[string]bool)
for _, item := range items {
s, ok := item.(string)
if !ok {
continue
}
t := strings.ToLower(strings.TrimSpace(s))
if t == "" || seen[t] {
continue
}
if !validSyncTypes[t] {
warnings = append(warnings, ValidationWarning{
Level: "api",
API: label,
Message: fmt.Sprintf("API %q sync_type has unknown device type %q (allowed: ap, switch, gateway)", label, s),
})
continue
}
seen[t] = true
result = append(result, t)
}
return result, warnings
}

// getIntFromMap safely extracts an int value from a map[string]interface{}
func getIntFromMap(m map[string]interface{}, key string) int {
if v, ok := m[key]; ok {
Expand Down
70 changes: 70 additions & 0 deletions internal/config/sync_type_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package config

import (
"reflect"
"testing"
)

func TestParseSyncTypes(t *testing.T) {
cases := []struct {
name string
nested map[string]interface{}
want []string
wantWarn bool
}{
{
name: "absent yields nil",
nested: map[string]interface{}{},
want: nil,
},
{
name: "empty list yields empty",
nested: map[string]interface{}{"sync_type": []interface{}{}},
want: nil,
},
{
name: "single type",
nested: map[string]interface{}{"sync_type": []interface{}{"ap"}},
want: []string{"ap"},
},
{
name: "normalizes case and whitespace",
nested: map[string]interface{}{"sync_type": []interface{}{"AP", " switch "}},
want: []string{"ap", "switch"},
},
{
name: "dedupes",
nested: map[string]interface{}{"sync_type": []interface{}{"ap", "ap", "gateway"}},
want: []string{"ap", "gateway"},
},
{
name: "unknown type warns and is skipped",
nested: map[string]interface{}{"sync_type": []interface{}{"bogus", "ap"}},
want: []string{"ap"},
wantWarn: true,
},
{
name: "wrong type warns",
nested: map[string]interface{}{"sync_type": "ap"},
want: nil,
wantWarn: true,
},
{
name: "tolerates []string",
nested: map[string]interface{}{"sync_type": []string{"gateway"}},
want: []string{"gateway"},
},
}

for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got, warnings := parseSyncTypes("test-api", c.nested)
if !reflect.DeepEqual(got, c.want) {
t.Errorf("got %v, want %v", got, c.want)
}
if (len(warnings) > 0) != c.wantWarn {
t.Errorf("warnings = %v, wantWarn = %v", warnings, c.wantWarn)
}
})
}
}
22 changes: 22 additions & 0 deletions internal/logging/logging.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package logging

import (
"bytes"
"fmt"
"io"
"os"
Expand Down Expand Up @@ -271,6 +272,27 @@ func Errorf(format string, args ...interface{}) {
defaultLogger.Errorf(format, args...)
}

// PauseOutput redirects the logger to an in-memory buffer and returns a release
// func. While paused, log lines accumulate instead of printing; release restores
// the prior destination and flushes everything buffered to it. A full-screen
// renderer (the refresh board) calls this so concurrent warnings — like MAC
// collisions during the index rebuild — don't corrupt the live display, then
// surface as a clean trailing block once the board tears down.
//
// Not reentrant: a second PauseOutput before release loses the first buffer's
// destination. The refresh path is the only caller and pauses once.
func PauseOutput() (release func()) {
prev := defaultLogger.Out
buf := &bytes.Buffer{}
defaultLogger.SetOutput(buf)
return func() {
defaultLogger.SetOutput(prev)
if buf.Len() > 0 {
_, _ = prev.Write(buf.Bytes())
}
}
}

// ConfigureLogger configures the logger with a simplified interface
// This is an alias for ConfigureLogging to maintain backward compatibility with existing code
func ConfigureLogger(level string, toStdout bool) error {
Expand Down
21 changes: 21 additions & 0 deletions internal/logging/logging_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -268,3 +268,24 @@ func TestNameFormatting(t *testing.T) {
t.Errorf("FormatOrgID(%s) = %s, want %s", unknownOrgID, formattedOrg, unknownOrgID)
}
}

func TestPauseOutputBuffersThenFlushes(t *testing.T) {
originalLogger := defaultLogger
defer func() { defaultLogger = originalLogger }()

defaultLogger = logrus.New()
var sink bytes.Buffer
defaultLogger.SetOutput(&sink)
defaultLogger.SetLevel(logrus.InfoLevel)

release := PauseOutput()
Warnf("collision while paused")
if sink.Len() != 0 {
t.Fatalf("paused log leaked to sink: %q", sink.String())
}

release()
if !strings.Contains(sink.String(), "collision while paused") {
t.Fatalf("release did not flush buffered log; sink=%q", sink.String())
}
}
Loading