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
159 changes: 117 additions & 42 deletions cmd/multivendor_show.go
Original file line number Diff line number Diff line change
Expand Up @@ -332,9 +332,18 @@ func showSitesMultiVendor(_ context.Context, parsed *cmdutils.ParsedShowArgs) er
return err
}

// Armed device MACs, to split each site's device counts into managed vs
// unmanaged. A device is exactly one type and inventory maps key on the
// normalized MAC, so one combined set suffices.
managedMACs, err := loadManagedMACSet([]string{"ap", "switch", "gateway"})
if err != nil {
return err
}

// Collect sites from all target APIs (list view)
var allSites []formatter.GenericTableData
apiCounts := make(map[string]int)
usedManaged := false // any M flag emitted -> render the legend

for _, apiLabel := range targetAPIs {
cache, err := cacheMgr.GetAPICache(apiLabel)
Expand All @@ -356,39 +365,73 @@ func showSitesMultiVendor(_ context.Context, parsed *cmdutils.ParsedShowArgs) er
}
}

// Count devices for this site
apCount := 0
switchCount := 0
gwCount := 0

for _, item := range cache.Inventory.AP {
if item.SiteID == site.ID {
apCount++
// Count devices for this site, splitting managed (armed) from the
// rest. A managed site can still hold unmanaged devices, so the
// split is informative in both views.
apTotal, apManaged := 0, 0
for mac, item := range cache.Inventory.AP {
if item.SiteID != site.ID {
continue
}
apTotal++
if managedMACs[vendors.NormalizeMAC(mac)] {
apManaged++
}
}
for _, item := range cache.Inventory.Switch {
if item.SiteID == site.ID {
switchCount++
switchTotal, switchManaged := 0, 0
for mac, item := range cache.Inventory.Switch {
if item.SiteID != site.ID {
continue
}
switchTotal++
if managedMACs[vendors.NormalizeMAC(mac)] {
switchManaged++
}
}
for _, item := range cache.Inventory.Gateway {
if item.SiteID == site.ID {
gwCount++
gwTotal, gwManaged := 0, 0
for mac, item := range cache.Inventory.Gateway {
if item.SiteID != site.ID {
continue
}
gwTotal++
if managedMACs[vendors.NormalizeMAC(mac)] {
gwManaged++
}
}

// Convert to table data
// In the widened (`all`) view, flag managed sites and embolden the
// name so they stand out among the unmanaged. The default view is
// already all-managed, so a flag there is noise.
siteManaged := managedSites[strings.ToLower(site.Name)]
displayName := site.Name
var flags string
if parsed.ShowUnmanaged && siteManaged {
flags = flagManaged
usedManaged = true
displayName = "BOLD_TEXT:" + site.Name
}

// Numeric fields stay primary for JSON/CSV; the `M/U` strings are a
// table-only convenience.
data := formatter.GenericTableData{
"name": site.Name,
"id": site.ID,
"timezone": site.Timezone,
"country_code": site.CountryCode,
"ap_count": apCount,
"switch_count": switchCount,
"gw_count": gwCount,
"total": apCount + switchCount + gwCount,
"vendor": cache.Meta.Vendor,
"api": apiLabel,
"name": displayName,
"flags": flags,
"id": site.ID,
"timezone": site.Timezone,
"country_code": site.CountryCode,
"managed": siteManaged,
"ap_count": apTotal,
"ap_managed": apManaged,
"ap_mu": fmt.Sprintf("%d/%d", apManaged, apTotal-apManaged),
"switch_count": switchTotal,
"switch_managed": switchManaged,
"switch_mu": fmt.Sprintf("%d/%d", switchManaged, switchTotal-switchManaged),
"gw_count": gwTotal,
"gw_managed": gwManaged,
"gw_mu": fmt.Sprintf("%d/%d", gwManaged, gwTotal-gwManaged),
"total": apTotal + switchTotal + gwTotal,
"vendor": cache.Meta.Vendor,
"api": apiLabel,
}

allSites = append(allSites, data)
Expand Down Expand Up @@ -428,23 +471,6 @@ func showSitesMultiVendor(_ context.Context, parsed *cmdutils.ParsedShowArgs) er
return nil
}

// Determine columns - add API column when showing multiple APIs
columns := []formatter.TableColumn{
{Field: "name", Title: "Name", MaxWidth: 0},
{Field: "timezone", Title: "Timezone", MaxWidth: 0},
{Field: "ap_count", Title: "APs", MaxWidth: 0},
{Field: "switch_count", Title: "Switches", MaxWidth: 0},
{Field: "gw_count", Title: "Gateways", MaxWidth: 0},
}

// Add vendor and API columns when showing from multiple APIs
if len(targetAPIs) > 1 || apiFlag == "" {
columns = append(columns,
formatter.TableColumn{Field: "vendor", Title: "Vendor", MaxWidth: 0},
formatter.TableColumn{Field: "api", Title: "API", MaxWidth: 0},
)
}

// Create command path for config lookup
commandPath := "show.sites"

Expand All @@ -467,6 +493,54 @@ func showSitesMultiVendor(_ context.Context, parsed *cmdutils.ParsedShowArgs) er
}
}

// Resolve the effective format before building columns: CSV gets numeric
// columns, the table gets the combined `M/U` cells and the flags column.
effFormat := parsed.Format
if effFormat == "" && hasCommandConfig {
effFormat = commandFormat.Format
}

var flagLegend []formatter.FlagDef
if usedManaged {
flagLegend = append(flagLegend, formatter.FlagDef{Key: flagManaged, Description: "managed (armed in inventory)"})
}

var columns []formatter.TableColumn
if effFormat == "csv" {
// Machine output: split totals from managed counts, both numeric.
columns = []formatter.TableColumn{
{Field: "name", Title: "Name", MaxWidth: 0},
{Field: "timezone", Title: "Timezone", MaxWidth: 0},
{Field: "managed", Title: "Managed", MaxWidth: 0, IsBoolField: true},
{Field: "ap_count", Title: "APs", MaxWidth: 0},
{Field: "ap_managed", Title: "APs Managed", MaxWidth: 0},
{Field: "switch_count", Title: "Switches", MaxWidth: 0},
{Field: "switch_managed", Title: "Switches Managed", MaxWidth: 0},
{Field: "gw_count", Title: "Gateways", MaxWidth: 0},
{Field: "gw_managed", Title: "Gateways Managed", MaxWidth: 0},
}
} else {
columns = []formatter.TableColumn{{Field: "name", Title: "Name", MaxWidth: 0}}
// Flags column slots in after Name only when a flag is present.
if len(flagLegend) > 0 {
columns = append(columns, formatter.TableColumn{Field: "flags", Title: "Flags", MaxWidth: 0})
}
columns = append(columns,
formatter.TableColumn{Field: "timezone", Title: "Timezone", MaxWidth: 0},
formatter.TableColumn{Field: "ap_mu", Title: "APs (M/U)", MaxWidth: 0},
formatter.TableColumn{Field: "switch_mu", Title: "Switches (M/U)", MaxWidth: 0},
formatter.TableColumn{Field: "gw_mu", Title: "Gateways (M/U)", MaxWidth: 0},
)
}

// Add vendor and API columns when showing from multiple APIs
if len(targetAPIs) > 1 || apiFlag == "" {
columns = append(columns,
formatter.TableColumn{Field: "vendor", Title: "Vendor", MaxWidth: 0},
formatter.TableColumn{Field: "api", Title: "API", MaxWidth: 0},
)
}

// Create cache accessor for cache.* field lookups
cacheAccessor, err := cmdutils.NewCacheTableAccessor()
if err != nil {
Expand All @@ -489,6 +563,7 @@ func showSitesMultiVendor(_ context.Context, parsed *cmdutils.ParsedShowArgs) er
CacheAccess: cacheAccessor,
ShowAllFields: parsed.AllFields(),
Columns: columns,
FlagLegend: flagLegend,
}

// Set format from config if not overridden by argument
Expand Down
10 changes: 7 additions & 3 deletions docs/table-formatter.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,16 +35,20 @@ Color encodes exactly one axis — **device operational state** — in the statu
(online → green `C`, offline → `D`, alerting → yellow `A`, dormant → blue `Z`). Metadata
that isn't state (managed, drift) never tints text; it rides a **flags channel** instead.

Device tables (`show ap|switch|gateway`) carry a `Flags` column when any flag is present:
Device tables (`show ap|switch|gateway`) and the site list (`show site`) carry a `Flags`
column when any flag is present:

| Flag | Meaning |
|------|---------|
| `M` | managed — armed in the per-site `inventory.json` |
| `*` | config drift — local intent differs from the cached config |

On the site list, `M` marks a site that holds at least one armed device, shown only in the
widened `all` view (the default view is already managed-only, so the flag would be noise).

Below the table, a legend lists **only the flags actually present**, one per line with the
key emphasized (theme-safe bold, not color). A managed device's name is shown in the same
bold emphasis so it scans quickly in the widened `all` view, without implying "up."
key emphasized (theme-safe bold, not color). A managed device's (or site's) name is shown in
the same bold emphasis so it scans quickly in the widened `all` view, without implying "up."

A table opts in by setting `TableConfig.FlagLegend` (a `[]FlagDef` of `{Key, Description}`)
and emitting a `flags` field per row. The caller passes only the present flags. Inline cell
Expand Down
6 changes: 6 additions & 0 deletions docs/user-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,12 @@ wifimgr show ap all # Every AP the API knows (managed highligh
wifimgr show ap Lobby-AP format json # A single AP as JSON
```

The site list splits its device columns into managed/unmanaged: `APs (M/U)`,
`Switches (M/U)`, `Gateways (M/U)` — so a managed site that still holds unarmed devices
reads at a glance. In the widened `show sites all` view, sites that hold at least one armed
device carry an `M` flag. JSON and CSV keep numeric fields (`ap_count`, `ap_managed`, …);
the `M/U` string is table-only.

### Data Sources

| Command | Source | Description |
Expand Down
Loading