Skip to content
Open
Show file tree
Hide file tree
Changes from 8 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
32 changes: 32 additions & 0 deletions LICENSE-3rdparty.csv

Large diffs are not rendered by default.

10 changes: 8 additions & 2 deletions cmd/agent/subcommands/run/command_notwin.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,14 @@

package run

import "go.uber.org/fx"
import (
"go.uber.org/fx"

softwareinventoryfx "github.com/DataDog/datadog-agent/comp/softwareinventory/fx"
)

func getPlatformModules() fx.Option {
return fx.Options()
return fx.Options(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not sure about this file name, should not it have darwin in the name? Does it mean it will be executed on Linux?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was in response to #45533 (comment)

See: fe8b555

softwareinventoryfx.Module(),
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@
// This product includes software developed at Datadog (https://www.datadoghq.com/).
// Copyright 2016-present Datadog, Inc.

//go:build darwin || windows

package modules

import (
"github.com/DataDog/datadog-agent/pkg/inventory/software"
"net/http"

"github.com/DataDog/datadog-agent/pkg/inventory/software"
"github.com/DataDog/datadog-agent/pkg/system-probe/api/module"
"github.com/DataDog/datadog-agent/pkg/system-probe/config"
sysconfigtypes "github.com/DataDog/datadog-agent/pkg/system-probe/config/types"
Expand All @@ -29,8 +31,7 @@ var SoftwareInventory = &module.Factory{

var _ module.Module = &softwareInventoryModule{}

type softwareInventoryModule struct {
}
type softwareInventoryModule struct{}

func (sim *softwareInventoryModule) Register(httpMux *module.Router) error {
httpMux.HandleFunc("/check", utils.WithConcurrencyLimit(1, func(w http.ResponseWriter, _ *http.Request) {
Expand All @@ -54,6 +55,4 @@ func (sim *softwareInventoryModule) GetStats() map[string]interface{} {
return map[string]interface{}{}
}

func (sim *softwareInventoryModule) Close() {

}
func (sim *softwareInventoryModule) Close() {}
19 changes: 19 additions & 0 deletions comp/core/gui/guiimpl/views/private/js/javascript.js
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,25 @@ $(document).on('click', '.load_more', function (e) {
loadMore();
});

// Delegate change handler for software inventory filter (works after DOMPurify sanitization)
$(document).on('change', '#sw-type-filter', function () {
var type = this.value;
var entries = document.querySelectorAll('.sw-entry');
var visibleCount = 0;
entries.forEach(function(entry) {
if (type === '' || entry.getAttribute('data-sw-type') === type) {
entry.style.display = '';
visibleCount++;
} else {
entry.style.display = 'none';
}
});
var countSpan = document.getElementById('sw-filter-count');
if (countSpan) {
countSpan.textContent = type !== '' ? 'Showing ' + visibleCount + ' entries' : '';
}
});

// Handler for loading more lines of the currently displayed log file
function loadMore() {
var data = $(".log_data").html();
Expand Down
31 changes: 27 additions & 4 deletions comp/softwareinventory/impl/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@ package softwareinventoryimpl
import (
"embed"
"io"
"strings"
"time"

"github.com/DataDog/datadog-agent/comp/core/status"
"github.com/DataDog/datadog-agent/pkg/inventory/software"
)

//go:embed status_templates
Expand Down Expand Up @@ -66,20 +68,41 @@ func formatYYYYMMDD(ts string) (string, error) {

// populateStatus populates the status map with software inventory data.
// This method processes the cached inventory data and formats it for display
// in the status output. It handles date formatting and organizes the data
// by software ID for easy lookup.
// in the status output. It handles date formatting, computes statistics by
// software type, and organizes the data by software ID for easy lookup.
// Note: Stats are computed from deduplicated entries to ensure consistency
// between the total count and the breakdown by type.
func (is *softwareInventory) populateStatus(status map[string]interface{}) {
data := map[string]interface{}{}

is.cachedInventoryMu.RLock()
cachedInventory := is.cachedInventory
is.cachedInventoryMu.RUnlock()

// First pass: deduplicate entries by ID and format dates
data := map[string]interface{}{}
Comment on lines +83 to +84
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why there is duplication?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have multiple inventory collectors which could report the same software from different sources, and the same software can appear in multiple locations. For example, Microsoft Word could appear in both application category and package category. The implemented duplication removal is the best effort and might not catch all cases.

for _, inventory := range cachedInventory {
inventory.InstallDate, _ = formatYYYYMMDD(inventory.InstallDate)
data[inventory.GetID()] = inventory
}

// Second pass: compute stats from deduplicated entries
// This ensures stats sum matches the total count
stats := map[string]int{}
brokenCount := 0
for _, v := range data {
inventory := v.(software.Entry)
stats[inventory.Source]++
if strings.Contains(inventory.Status, "broken") {
brokenCount++
}
}

status["software_inventory_metadata"] = data
status["software_inventory_stats"] = stats
status["software_inventory_total"] = len(data)
// Only include broken count if there are broken entries
if brokenCount > 0 {
status["software_inventory_broken"] = brokenCount
}
}

// getStatusInfo returns the status information map for the software inventory.
Expand Down
13 changes: 9 additions & 4 deletions comp/softwareinventory/impl/status_templates/inventory.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,15 @@
{{- if index $swMap "error" }}
Error refreshing software inventory: {{ index $swMap "error" }}
{{- else }}
{{- $count := 0 }}
{{- range $productCode, $meta := $swMap }}
{{- $count = add $count 1 }}
{{- $total := index . "software_inventory_total" }}
{{- $stats := index . "software_inventory_stats" }}
{{- $broken := index . "software_inventory_broken" }}
Detected {{ $total }} installed software entries{{- if $broken }} ({{ $broken }} broken){{- end }}.

By type:
{{- range $type, $count := $stats }}
{{ $type }}: {{ $count }}
{{- end }}
Detected {{ $count }} installed software entries.

The full list is not displayed in this status output, but is available in a flare, the Agent status page in the GUI, or in the Datadog Application.
{{- end }}
76 changes: 51 additions & 25 deletions comp/softwareinventory/impl/status_templates/inventoryHTML.tmpl
Original file line number Diff line number Diff line change
@@ -1,31 +1,57 @@
<div class="stat">
<span class="stat_title">Software Inventory Metadata</span>
<div class="stat_data inventory-scrollbox" style="max-height: 400px; overflow-y: auto;">
{{- $swMap := index . "software_inventory_metadata" }}
{{- if index $swMap "error" }}
<div>Error refreshing software inventory: {{ index $swMap "error" }}</div>
{{- else }}
{{- range $productCode, $meta := $swMap }}
<details>
<summary>
{{- if $meta.DisplayName }}{{ $meta.DisplayName }}{{ if $meta.Version }} {{ $meta.Version }}{{ end }}{{- else }}{{ $meta.ProductCode }}{{- end }}
{{- if contains $meta.Status "broken" }}
<span class="source-bubble broken">broken</span>
{{- end }}
</summary>
<ul style="margin:1em 0; padding-left:2em;">
{{- if $meta.DisplayName }}<li><strong>Display Name:</strong> {{ $meta.DisplayName }}</li>{{- end }}
{{- if $meta.Version }}<li><strong>Version:</strong> {{ $meta.Version }}</li>{{- end }}
{{- if $meta.InstallDate }}<li><strong>Install Date:</strong> {{ $meta.InstallDate }}</li>{{- end }}
{{- if $meta.Publisher }}<li><strong>Publisher:</strong> {{ $meta.Publisher }}</li>{{- end }}
{{- if $meta.ProductCode }}<li><strong>Product code:</strong> {{ $meta.ProductCode }}</li>{{- end }}
{{- if $meta.Source }}<li><strong>Source:</strong> {{ $meta.Source }}</li>{{- end }}
{{- if $meta.Status }}<li><strong>Status:</strong> {{ $meta.Status }}</li>{{- end }}
{{- if $meta.UserSID }}<li><strong>User SID:</strong> {{ $meta.UserSID }}</li>{{- end }}
<li><strong>64-bit:</strong> {{ $meta.Is64Bit }}</li>
</ul>
</details>
{{- $swMap := index . "software_inventory_metadata" }}
{{- $total := index . "software_inventory_total" }}
{{- $stats := index . "software_inventory_stats" }}
{{- $broken := index . "software_inventory_broken" }}
{{- if index $swMap "error" }}
<div>Error refreshing software inventory: {{ index $swMap "error" }}</div>
{{- else }}
<div style="margin-bottom: 1em; padding: 0.5em; background: #f5f5f5; border-radius: 4px;">
<strong>Summary:</strong> {{ $total }} entries{{- if $broken }} (<span style="color: #d00;">{{ $broken }} broken</span>){{- end }}
<br/>
<strong>By type:</strong>
<ul style="margin: 0.5em 0; padding-left: 2em; list-style: none;">
{{- range $type, $count := $stats }}
<li>{{ $type }}: {{ $count }}</li>
{{- end }}
</ul>
</div>
<div style="margin-bottom: 1em;">
<label for="sw-type-filter"><strong>Filter by type:</strong></label>
<select id="sw-type-filter" style="margin-left: 0.5em; padding: 0.25em 0.5em; border-radius: 4px; border: 1px solid #ccc;">
<option value="">All ({{ $total }})</option>
{{- range $type, $count := $stats }}
<option value="{{ $type }}">{{ $type }} ({{ $count }})</option>
{{- end }}
</select>
<span id="sw-filter-count" style="margin-left: 1em; color: #666;"></span>
</div>
<div id="sw-entries-container" class="stat_data inventory-scrollbox" style="max-height: 400px; overflow-y: auto;">
{{- range $productCode, $meta := $swMap }}
<details class="sw-entry" data-sw-type="{{ $meta.Source }}">
<summary>
{{- if $meta.DisplayName }}{{ $meta.DisplayName }}{{ if $meta.Version }} {{ $meta.Version }}{{ end }}{{- else }}{{ $meta.ProductCode }}{{- end }}
{{- if contains $meta.Status "broken" }}
<span class="source-bubble broken">broken</span>
{{- end }}
</summary>
<ul style="margin:1em 0; padding-left:2em;">
{{- if $meta.DisplayName }}<li><strong>Display Name:</strong> {{ $meta.DisplayName }}</li>{{- end }}
{{- if $meta.Version }}<li><strong>Version:</strong> {{ $meta.Version }}</li>{{- end }}
{{- if $meta.InstallDate }}<li><strong>Install Date:</strong> {{ $meta.InstallDate }}</li>{{- end }}
{{- if $meta.Publisher }}<li><strong>Publisher:</strong> {{ $meta.Publisher }}</li>{{- end }}
{{- if $meta.ProductCode }}<li><strong>Product code:</strong> {{ $meta.ProductCode }}</li>{{- end }}
{{- if $meta.Source }}<li><strong>Source:</strong> {{ $meta.Source }}</li>{{- end }}
{{- if $meta.Status }}<li><strong>Status:</strong> {{ $meta.Status }}</li>{{- end }}
{{- if $meta.BrokenReason }}<li><strong>Broken Reason:</strong> <span style="color: #d00;">{{ $meta.BrokenReason }}</span></li>{{- end }}
{{- if $meta.UserSID }}<li><strong>User SID:</strong> {{ $meta.UserSID }}</li>{{- end }}
{{- if $meta.InstallPath }}<li><strong>Install Path:</strong> {{ $meta.InstallPath }}</li>{{- end }}
{{- if $meta.InstallPaths }}<li><strong>Install Paths:</strong><ul style="margin: 0.25em 0; padding-left: 1.5em;">{{- range $meta.InstallPaths }}<li>{{ . }}</li>{{- end }}</ul></li>{{- end }}
<li><strong>64-bit:</strong> {{ $meta.Is64Bit }}</li>
</ul>
</details>
{{- end }}
</div>
{{- end }}
</div>
120 changes: 116 additions & 4 deletions comp/softwareinventory/impl/status_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ func TestStatusFundamentals(t *testing.T) {

func TestGetPayloadRefreshesCachedValues(t *testing.T) {
f := newFixtureWithData(t, true, []software.Entry{
{DisplayName: "FooApp", ProductCode: "foo"},
{DisplayName: "BarApp", ProductCode: "bar"},
{DisplayName: "FooApp", ProductCode: "foo", Source: "app"},
{DisplayName: "BarApp", ProductCode: "bar", Source: "pkg"},
})
is := f.sut().WaitForSystemProbe()

Expand All @@ -35,8 +35,11 @@ func TestGetPayloadRefreshesCachedValues(t *testing.T) {

// Assert that the cached values were refreshed
assert.NoError(t, err)
assert.Len(t, stats, 1)
// Now includes: software_inventory_metadata, software_inventory_stats, software_inventory_total
assert.Contains(t, stats, "software_inventory_metadata")
assert.Contains(t, stats, "software_inventory_stats")
assert.Contains(t, stats, "software_inventory_total")
assert.Equal(t, 2, stats["software_inventory_total"])
// Note: The exact structure of stats depends on how the JSON is marshaled
// This test may need adjustment based on the actual output format
f.sysProbeClient.AssertNumberOfCalls(t, "GetCheck", 1)
Expand Down Expand Up @@ -146,8 +149,117 @@ func TestStatusTemplateWithNoSoftwareInventoryMetadata(t *testing.T) {
err = is.HTML(false, &buf)
assert.NoError(t, err)
// Just verify the basic structure is present, since it will be empty
assert.Contains(t, buf.String(), `<div class="stat_data inventory-scrollbox"`)
assert.Contains(t, buf.String(), `class="stat_data inventory-scrollbox"`)
// Summary should show 0 entries
assert.Contains(t, buf.String(), "<strong>Summary:</strong> 0 entries")

// The populateStatus caches the values once.
f.sysProbeClient.AssertNumberOfCalls(t, "GetCheck", 1)
}

func TestStatusStatsComputation(t *testing.T) {
// Test that stats are correctly computed by software type
f := newFixtureWithData(t, true, []software.Entry{
{DisplayName: "Safari", ProductCode: "safari", Source: "app"},
{DisplayName: "Chrome", ProductCode: "chrome", Source: "app"},
{DisplayName: "git", ProductCode: "git", Source: "homebrew"},
{DisplayName: "wget", ProductCode: "wget", Source: "homebrew"},
{DisplayName: "curl", ProductCode: "curl", Source: "homebrew"},
{DisplayName: "Python Driver", ProductCode: "python-driver", Source: "pkg"},
})
is := f.sut().WaitForSystemProbe()

stats := make(map[string]interface{})
err := is.JSON(false, stats)
assert.NoError(t, err)

// Verify total count
assert.Equal(t, 6, stats["software_inventory_total"])

// Verify stats by type
typeStats, ok := stats["software_inventory_stats"].(map[string]int)
assert.True(t, ok, "software_inventory_stats should be map[string]int")
assert.Equal(t, 2, typeStats["app"])
assert.Equal(t, 3, typeStats["homebrew"])
assert.Equal(t, 1, typeStats["pkg"])

// Verify no broken count when no broken entries
_, hasBroken := stats["software_inventory_broken"]
assert.False(t, hasBroken, "software_inventory_broken should not be present when no broken entries")
}

func TestStatusBrokenCount(t *testing.T) {
// Test that broken entries are correctly counted
f := newFixtureWithData(t, true, []software.Entry{
{DisplayName: "GoodApp", ProductCode: "good", Source: "app", Status: "installed"},
{DisplayName: "BrokenApp", ProductCode: "broken1", Source: "app", Status: "broken"},
{DisplayName: "AnotherGood", ProductCode: "good2", Source: "pkg", Status: "installed"},
{DisplayName: "AnotherBroken", ProductCode: "broken2", Source: "pkg", Status: "broken"},
})
is := f.sut().WaitForSystemProbe()

stats := make(map[string]interface{})
err := is.JSON(false, stats)
assert.NoError(t, err)

// Verify total count
assert.Equal(t, 4, stats["software_inventory_total"])

// Verify broken count is present and correct
brokenCount, hasBroken := stats["software_inventory_broken"]
assert.True(t, hasBroken, "software_inventory_broken should be present when there are broken entries")
assert.Equal(t, 2, brokenCount)
}

func TestStatusTextTemplateWithStats(t *testing.T) {
f := newFixtureWithData(t, true, []software.Entry{
{DisplayName: "App1", ProductCode: "app1", Source: "app"},
{DisplayName: "App2", ProductCode: "app2", Source: "app"},
{DisplayName: "Brew1", ProductCode: "brew1", Source: "homebrew"},
})
is := f.sut().WaitForSystemProbe()

var buf bytes.Buffer
err := is.Text(false, &buf)
assert.NoError(t, err)

text := buf.String()
// Verify total count in text output
assert.Contains(t, text, "Detected 3 installed software entries")
// Verify "By type:" section header
assert.Contains(t, text, "By type:")
}

func TestStatusTextTemplateWithBrokenEntries(t *testing.T) {
f := newFixtureWithData(t, true, []software.Entry{
{DisplayName: "GoodApp", ProductCode: "good", Source: "app", Status: "installed"},
{DisplayName: "BrokenApp", ProductCode: "broken", Source: "app", Status: "broken"},
})
is := f.sut().WaitForSystemProbe()

var buf bytes.Buffer
err := is.Text(false, &buf)
assert.NoError(t, err)

text := buf.String()
// Verify broken count appears in text output
assert.Contains(t, text, "Detected 2 installed software entries (1 broken)")
}

func TestStatusHTMLTemplateWithStats(t *testing.T) {
f := newFixtureWithData(t, true, []software.Entry{
{DisplayName: "App1", ProductCode: "app1", Source: "app"},
{DisplayName: "Brew1", ProductCode: "brew1", Source: "homebrew"},
})
is := f.sut().WaitForSystemProbe()

var buf bytes.Buffer
err := is.HTML(false, &buf)
assert.NoError(t, err)

html := buf.String()
// Verify summary section
assert.Contains(t, html, "<strong>Summary:</strong> 2 entries")
// Verify "By type:" section
assert.Contains(t, html, "<strong>By type:</strong>")
}
Loading