diff --git a/cmd/api/handlers/nodes.go b/cmd/api/handlers/nodes.go index 6e6d658b..474b8dd7 100644 --- a/cmd/api/handlers/nodes.go +++ b/cmd/api/handlers/nodes.go @@ -2,6 +2,7 @@ package handlers import ( "encoding/json" + "errors" "fmt" "net/http" "strconv" @@ -14,6 +15,7 @@ import ( "github.com/jmpsec/osctrl/pkg/users" "github.com/jmpsec/osctrl/pkg/utils" "github.com/rs/zerolog/log" + "gorm.io/gorm" ) // NodeHandler - GET Handler for single JSON nodes @@ -79,12 +81,8 @@ func (h *HandlersApi) NodeHandler(w http.ResponseWriter, r *http.Request) { // enrichment fields (CPU cores, BIOS, hardware vendor/model) parsed from // the otherwise-hidden RawEnrollment blob. The enroll_secret inside that // blob is intentionally NOT in the projection — see pkg/types/node_view.go. - view := types.ProjectNode(node) + view := h.projectNode(node) view.NodeKey = node.NodeKey - // Resolve the node's IP to a country code via GeoIP (if configured). - if h.GeoIP != nil && node.IPAddress != "" { - view.CountryCode = h.GeoIP.Lookup(node.IPAddress) - } utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, view) } @@ -450,11 +448,41 @@ func (h *HandlersApi) LookupNodeHandler(w http.ResponseWriter, r *http.Request) utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, n) } +func (h *HandlersApi) projectNode(node nodes.OsqueryNode) types.NodeView { + var countryCode string + if h.GeoIP != nil && node.IPAddress != "" { + countryCode = h.GeoIP.Lookup(node.IPAddress) + } + var uptime *types.NodeUptime + if h.PostureEnabled && h.Posture != nil { + var err error + uptime, err = h.Posture.GetUptimeByNode(node.UUID) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + log.Warn().Err(err).Str("node_uuid", node.UUID).Msg("posture: failed to load node uptime") + } + } + return types.ProjectNodeWithCountryAndUptime(node, countryCode, uptime) +} + func (h *HandlersApi) projectNodesWithGeo(in []nodes.OsqueryNode) []types.NodeView { - if h.GeoIP == nil { - return types.ProjectNodes(in) + var lookup func(string) string + if h.GeoIP != nil { + lookup = h.GeoIP.Lookup + } + uptimes := map[string]*types.NodeUptime{} + if h.PostureEnabled && h.Posture != nil && len(in) > 0 { + uuids := make([]string, 0, len(in)) + for _, node := range in { + uuids = append(uuids, node.UUID) + } + var err error + uptimes, err = h.Posture.GetUptimeByNodes(uuids) + if err != nil { + log.Warn().Err(err).Msg("posture: failed to load node uptime batch") + uptimes = map[string]*types.NodeUptime{} + } } - return types.ProjectNodesWithCountry(in, h.GeoIP.Lookup) + return types.ProjectNodesWithCountryAndUptime(in, lookup, uptimes) } // NodesPagedHandler returns paginated, sorted, searchable nodes for an env. diff --git a/cmd/api/handlers/nodes_posture_test.go b/cmd/api/handlers/nodes_posture_test.go new file mode 100644 index 00000000..30d64b18 --- /dev/null +++ b/cmd/api/handlers/nodes_posture_test.go @@ -0,0 +1,55 @@ +package handlers + +import ( + "testing" + + "github.com/jmpsec/osctrl/pkg/nodes" + "github.com/jmpsec/osctrl/pkg/posture" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func TestProjectNodeAddsUptimeWhenPostureEnabled(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:node_uptime_projection?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + pm := posture.NewPostureManager(db) + if err := pm.IngestResult("node-a", "env", posture.QueryPrefix+"uptime", []byte(`[{"days":"2","hours":"4","minutes":"6","seconds":"8","total_seconds":"187568"}]`)); err != nil { + t.Fatalf("ingest uptime: %v", err) + } + h := &HandlersApi{ + Posture: pm, + PostureEnabled: true, + } + + view := h.projectNode(nodes.OsqueryNode{UUID: "node-a"}) + + if view.Uptime == nil { + t.Fatal("expected uptime") + } + if view.Uptime.Days != 2 || view.Uptime.Hours != 4 || view.Uptime.Minutes != 6 || view.Uptime.Seconds != 8 { + t.Fatalf("unexpected uptime: %+v", view.Uptime) + } +} + +func TestProjectNodeOmitsUptimeWhenPostureDisabled(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:node_uptime_disabled?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + pm := posture.NewPostureManager(db) + if err := pm.IngestResult("node-a", "env", posture.QueryPrefix+"uptime", []byte(`[{"days":"2","hours":"4","minutes":"6","seconds":"8"}]`)); err != nil { + t.Fatalf("ingest uptime: %v", err) + } + h := &HandlersApi{ + Posture: pm, + PostureEnabled: false, + } + + view := h.projectNode(nodes.OsqueryNode{UUID: "node-a"}) + + if view.Uptime != nil { + t.Fatalf("expected uptime to be hidden while posture is disabled: %+v", view.Uptime) + } +} diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 630123f7..456589ac 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -64,6 +64,15 @@ export interface NodeEnrichment { osquery?: NodeOsqueryRuntime; } +export interface NodeUptime { + days: number; + hours: number; + minutes: number; + seconds: number; + total_seconds?: number; + last_seen?: string; +} + export interface OsqueryNode { id: number; created_at: string; @@ -93,6 +102,8 @@ export interface OsqueryNode { country_code?: string; /** Optional enrichment parsed server-side from RawEnrollment (no secrets). */ system_info?: NodeEnrichment; + /** Optional uptime populated from the latest posture uptime result. */ + uptime?: NodeUptime; } export type NodeStatus = 'all' | 'active' | 'inactive'; diff --git a/frontend/src/features/nodes/NodeDetailPage.test.tsx b/frontend/src/features/nodes/NodeDetailPage.test.tsx index 2601d107..3d5e78e4 100644 --- a/frontend/src/features/nodes/NodeDetailPage.test.tsx +++ b/frontend/src/features/nodes/NodeDetailPage.test.tsx @@ -335,6 +335,56 @@ describe('NodeDetailPage', () => { expect(mockGetNodePosture).not.toHaveBeenCalled(); }); + it('shows posture uptime in lifecycle details only when posture is enabled', async () => { + mockGetFeatures.mockResolvedValue({ posture: true, accelerated: false }); + mockGetNode.mockResolvedValue( + makeNode({ + uptime: { + days: 7, + hours: 3, + minutes: 12, + seconds: 9, + total_seconds: 616329, + last_seen: '2026-07-26T10:30:00Z', + }, + }), + ); + + renderWithProviders(makeTestRouter()); + + await waitFor(() => { + expect(screen.getByRole('heading', { name: 'web-server-01' })).toBeInTheDocument(); + }); + + expect(screen.getByText('Uptime')).toBeInTheDocument(); + expect(screen.getByText('7d 3h 12m')).toBeInTheDocument(); + }); + + it('hides node uptime while posture is disabled', async () => { + mockGetFeatures.mockResolvedValue({ posture: false, accelerated: false }); + mockGetNode.mockResolvedValue( + makeNode({ + uptime: { + days: 7, + hours: 3, + minutes: 12, + seconds: 9, + total_seconds: 616329, + last_seen: '2026-07-26T10:30:00Z', + }, + }), + ); + + renderWithProviders(makeTestRouter()); + + await waitFor(() => { + expect(screen.getByRole('heading', { name: 'web-server-01' })).toBeInTheDocument(); + }); + + expect(screen.queryByText('Uptime')).not.toBeInTheDocument(); + expect(screen.queryByText('7d 3h 12m')).not.toBeInTheDocument(); + }); + it('shows the console action only when accelerated queries are enabled', async () => { mockGetFeatures.mockResolvedValue({ posture: false, accelerated: true }); const router = makeTestRouter(); diff --git a/frontend/src/features/nodes/NodeDetailPage.tsx b/frontend/src/features/nodes/NodeDetailPage.tsx index 2739606e..d8475f21 100644 --- a/frontend/src/features/nodes/NodeDetailPage.tsx +++ b/frontend/src/features/nodes/NodeDetailPage.tsx @@ -4,7 +4,7 @@ import { useParams, Link, useNavigate } from '@tanstack/react-router'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { Terminal } from 'lucide-react'; import { getNode, listNodeLogs, deleteNode, getNodePosture, getNodePostureScore } from '$/api/nodes'; -import type { NodePosture, PostureScore } from '$/api/types'; +import type { NodePosture, NodeUptime, PostureScore } from '$/api/types'; import { getMe } from '$/api/users'; import { listEnvironments } from '$/api/environments'; import { @@ -126,6 +126,16 @@ function fmtBytes(n: number): string { return `${n} B`; } +function formatNodeUptime(uptime?: NodeUptime): string { + if (!uptime) return '—'; + const parts: string[] = []; + if (uptime.days > 0) parts.push(`${uptime.days}d`); + if (uptime.hours > 0 || parts.length > 0) parts.push(`${uptime.hours}h`); + if (uptime.minutes > 0 || parts.length > 0) parts.push(`${uptime.minutes}m`); + if (parts.length === 0) parts.push(`${uptime.seconds}s`); + return parts.join(' '); +} + interface HeroStripProps { node: { platform: string; @@ -1106,6 +1116,19 @@ export function NodeDetailPage() { + {formatNodeUptime(node.uptime)} + + ), + }, + ] : []), { label: 'First seen', value: ( diff --git a/pkg/posture/posture.go b/pkg/posture/posture.go index 8a884659..ceeb53e7 100644 --- a/pkg/posture/posture.go +++ b/pkg/posture/posture.go @@ -4,9 +4,11 @@ import ( "bytes" "encoding/json" "fmt" + "strconv" "strings" "time" + "github.com/jmpsec/osctrl/pkg/types" "github.com/rs/zerolog/log" "gorm.io/gorm" "gorm.io/gorm/clause" @@ -22,6 +24,8 @@ import ( // overloading agents or the logging pipeline. const DefaultQueryPrefix = "osctrl:posture:" +const UptimeCategory = "uptime" + // QueryPrefix is the active posture query prefix. It is configured once at TLS // startup. Empty disables posture ingestion. var QueryPrefix = DefaultQueryPrefix @@ -266,6 +270,165 @@ func (pm *PostureManager) GetByNodeCategory(nodeUUID, category string) (*NodePos return &record, nil } +// ParseUptime converts the latest uptime posture result into node metadata. +func ParseUptime(record NodePosture) (*types.NodeUptime, error) { + if record.Category != UptimeCategory { + return nil, fmt.Errorf("posture category %q is not uptime", record.Category) + } + rows, err := parsePostureRows(record.Summary) + if err != nil || len(rows) == 0 { + rows, err = parsePostureRows(record.Snapshot) + } + if err != nil { + return nil, err + } + if len(rows) == 0 { + return nil, nil + } + row := rows[0] + days, err := parseUptimeInt(row, "days") + if err != nil { + return nil, err + } + hours, err := parseUptimeInt(row, "hours") + if err != nil { + return nil, err + } + minutes, err := parseUptimeInt(row, "minutes") + if err != nil { + return nil, err + } + seconds, err := parseUptimeInt(row, "seconds") + if err != nil { + return nil, err + } + totalSeconds, _ := parseUptimeInt64(row, "total_seconds") + return &types.NodeUptime{ + Days: days, + Hours: hours, + Minutes: minutes, + Seconds: seconds, + TotalSeconds: totalSeconds, + LastSeen: record.LastSeen, + }, nil +} + +func parsePostureRows(raw string) ([]map[string]json.RawMessage, error) { + trimmed := bytes.TrimSpace([]byte(raw)) + if len(trimmed) == 0 { + return nil, nil + } + switch trimmed[0] { + case '[': + var rows []map[string]json.RawMessage + if err := json.Unmarshal(trimmed, &rows); err != nil { + return nil, fmt.Errorf("parse posture rows: %w", err) + } + return rows, nil + case '{': + var row map[string]json.RawMessage + if err := json.Unmarshal(trimmed, &row); err != nil { + return nil, fmt.Errorf("parse posture row: %w", err) + } + return []map[string]json.RawMessage{row}, nil + default: + return nil, fmt.Errorf("posture rows must be a JSON object or array") + } +} + +func parseUptimeInt(row map[string]json.RawMessage, key string) (int, error) { + raw, ok := row[key] + if !ok { + return 0, fmt.Errorf("uptime missing %q", key) + } + var text string + if err := json.Unmarshal(raw, &text); err == nil { + n, err := strconv.ParseInt(text, 10, strconv.IntSize) + if err != nil { + return 0, fmt.Errorf("parse uptime %s: %w", key, err) + } + return int(n), nil + } + var num json.Number + if err := json.Unmarshal(raw, &num); err != nil { + return 0, fmt.Errorf("parse uptime %s: %w", key, err) + } + n, err := strconv.ParseInt(num.String(), 10, strconv.IntSize) + if err != nil { + return 0, fmt.Errorf("parse uptime %s: %w", key, err) + } + return int(n), nil +} + +func parseUptimeInt64(row map[string]json.RawMessage, key string) (int64, error) { + raw, ok := row[key] + if !ok { + return 0, fmt.Errorf("uptime missing %q", key) + } + var text string + if err := json.Unmarshal(raw, &text); err == nil { + n, err := strconv.ParseInt(text, 10, 64) + if err != nil { + return 0, fmt.Errorf("parse uptime %s: %w", key, err) + } + return n, nil + } + var num json.Number + if err := json.Unmarshal(raw, &num); err != nil { + return 0, fmt.Errorf("parse uptime %s: %w", key, err) + } + n, err := num.Int64() + if err != nil { + return 0, fmt.Errorf("parse uptime %s: %w", key, err) + } + return n, nil +} + +func (pm *PostureManager) GetUptimeByNode(nodeUUID string) (*types.NodeUptime, error) { + record, err := pm.GetByNodeCategory(nodeUUID, UptimeCategory) + if err != nil { + return nil, err + } + return ParseUptime(*record) +} + +func (pm *PostureManager) GetUptimeByNodes(nodeUUIDs []string) (map[string]*types.NodeUptime, error) { + out := make(map[string]*types.NodeUptime) + if len(nodeUUIDs) == 0 { + return out, nil + } + upperUUIDs := make([]string, 0, len(nodeUUIDs)) + seen := make(map[string]struct{}, len(nodeUUIDs)) + for _, uuid := range nodeUUIDs { + upper := strings.ToUpper(uuid) + if upper == "" { + continue + } + if _, ok := seen[upper]; ok { + continue + } + seen[upper] = struct{}{} + upperUUIDs = append(upperUUIDs, upper) + } + if len(upperUUIDs) == 0 { + return out, nil + } + var records []NodePosture + if err := pm.DB.Where("node_uuid IN ? AND category = ?", upperUUIDs, UptimeCategory).Find(&records).Error; err != nil { + return nil, err + } + for _, record := range records { + uptime, err := ParseUptime(record) + if err != nil { + return nil, err + } + if uptime != nil { + out[record.NodeUUID] = uptime + } + } + return out, nil +} + // FleetCategorySummary is a per-category summary across all nodes in an environment. type FleetCategorySummary struct { Category string `json:"category"` diff --git a/pkg/posture/posture_test.go b/pkg/posture/posture_test.go index 9f0971bb..c9cbbf4e 100644 --- a/pkg/posture/posture_test.go +++ b/pkg/posture/posture_test.go @@ -167,6 +167,50 @@ func TestIngestResultRejectsMalformedColumns(t *testing.T) { } } +func TestParseUptimeFromRecord(t *testing.T) { + record := NodePosture{ + Category: "uptime", + Summary: `[{"days":"7","hours":"3","minutes":"12","seconds":"9","total_seconds":"616329"}]`, + LastSeen: time.Date(2026, 7, 26, 10, 30, 0, 0, time.UTC), + } + + uptime, err := ParseUptime(record) + if err != nil { + t.Fatalf("parse uptime: %v", err) + } + if uptime == nil { + t.Fatal("expected uptime") + } + if uptime.Days != 7 || uptime.Hours != 3 || uptime.Minutes != 12 || uptime.Seconds != 9 { + t.Fatalf("unexpected uptime parts: %+v", uptime) + } + if uptime.TotalSeconds != 616329 { + t.Fatalf("unexpected total seconds %d", uptime.TotalSeconds) + } + if !uptime.LastSeen.Equal(record.LastSeen) { + t.Fatalf("unexpected last seen %s", uptime.LastSeen) + } +} + +func TestGetUptimeByNodesNormalizesUUIDs(t *testing.T) { + pm := newTestManager(t) + if err := pm.IngestResult("node-a", "dev", QueryPrefix+"uptime", json.RawMessage(`[{"days":"1","hours":"2","minutes":"3","seconds":"4"}]`)); err != nil { + t.Fatalf("ingest uptime: %v", err) + } + + uptimes, err := pm.GetUptimeByNodes([]string{"node-a"}) + if err != nil { + t.Fatalf("get uptime batch: %v", err) + } + uptime := uptimes["NODE-A"] + if uptime == nil { + t.Fatalf("missing uptime for NODE-A: %+v", uptimes) + } + if uptime.Days != 1 || uptime.Hours != 2 || uptime.Minutes != 3 || uptime.Seconds != 4 { + t.Fatalf("unexpected uptime: %+v", uptime) + } +} + type legacyNodePosture struct { ID uint `gorm:"primarykey"` CreatedAt time.Time diff --git a/pkg/posture/templates.go b/pkg/posture/templates.go index 6523c9f1..c5902abd 100644 --- a/pkg/posture/templates.go +++ b/pkg/posture/templates.go @@ -25,6 +25,16 @@ type ProfileQuery struct { Snapshot bool `json:"snapshot"` } +const uptimeProfileQuery = "SELECT days, hours, minutes, seconds, total_seconds FROM uptime" + +func uptimeCheck() ProfileQuery { + return ProfileQuery{ + Query: uptimeProfileQuery, + Interval: 86400, + Snapshot: true, + } +} + // ToScheduleEntries converts a profile's queries into a JSON map suitable // for merging into an environment's schedule config section. func (p PostureProfile) ToScheduleEntries() (map[string]map[string]interface{}, error) { @@ -94,6 +104,7 @@ func WindowsServerProfile() PostureProfile { Query: "SELECT name, version, publisher, install_date FROM programs ORDER BY name", Interval: 86400, Platform: "windows", Snapshot: true, }, + "uptime": uptimeCheck(), "users": { Query: "SELECT username, uid, gid, shell, type FROM users WHERE shell IS NOT NULL AND shell != '' AND shell NOT LIKE '%/nologin' AND shell NOT LIKE '%/false' ORDER BY username", Interval: 86400, Snapshot: true, @@ -145,6 +156,7 @@ func LinuxServerProfile() PostureProfile { Query: "SELECT name, version, release, '' AS repo FROM rpm_packages ORDER BY name", Interval: 86400, Platform: "linux", Snapshot: true, }, + "uptime": uptimeCheck(), "users": { Query: "SELECT username, uid, gid, shell, type FROM users WHERE shell IS NOT NULL AND shell != '' AND shell NOT LIKE '%/nologin' AND shell NOT LIKE '%/false' ORDER BY username", Interval: 86400, Snapshot: true, @@ -200,6 +212,7 @@ func MacOSLaptopProfile() PostureProfile { Query: "SELECT name, bundle_identifier AS bundle_id, bundle_short_version AS version FROM apps ORDER BY name", Interval: 86400, Platform: "darwin", Snapshot: true, }, + "uptime": uptimeCheck(), "users": { Query: "SELECT username, uid, gid, shell, type FROM users WHERE shell IS NOT NULL AND shell != '' AND shell NOT LIKE '%/nologin' AND shell NOT LIKE '%/false' ORDER BY username", Interval: 86400, Snapshot: true, @@ -251,6 +264,7 @@ func WindowsLaptopProfile() PostureProfile { Query: "SELECT name, version, publisher, install_date FROM programs ORDER BY name", Interval: 86400, Platform: "windows", Snapshot: true, }, + "uptime": uptimeCheck(), "users": { Query: "SELECT username, uid, gid, shell, type FROM users WHERE shell IS NOT NULL AND shell != '' AND shell NOT LIKE '%/nologin' AND shell NOT LIKE '%/false' ORDER BY username", Interval: 86400, Snapshot: true, @@ -302,6 +316,7 @@ func LinuxLaptopProfile() PostureProfile { Query: "SELECT name, version, release, '' AS repo FROM rpm_packages ORDER BY name", Interval: 86400, Platform: "linux", Snapshot: true, }, + "uptime": uptimeCheck(), "users": { Query: "SELECT username, uid, gid, shell, type FROM users WHERE shell IS NOT NULL AND shell != '' AND shell NOT LIKE '%/nologin' AND shell NOT LIKE '%/false' ORDER BY username", Interval: 86400, Snapshot: true, diff --git a/pkg/posture/templates_test.go b/pkg/posture/templates_test.go index 06c166da..3a5c57e8 100644 --- a/pkg/posture/templates_test.go +++ b/pkg/posture/templates_test.go @@ -67,6 +67,21 @@ func TestWindowsServicesUseOsqueryStatusValue(t *testing.T) { } } +func TestProfilesCollectUptime(t *testing.T) { + for _, profile := range AllProfiles() { + query, ok := profile.Queries["uptime"] + if !ok { + t.Fatalf("%s has no uptime query", profile.ID) + } + if query.Query != "SELECT days, hours, minutes, seconds, total_seconds FROM uptime" { + t.Fatalf("%s uptime query = %q", profile.ID, query.Query) + } + if !query.Snapshot { + t.Fatalf("%s uptime query must be a snapshot", profile.ID) + } + } +} + func TestProfileTablesSupportTargetPlatform(t *testing.T) { type schemaTable struct { Name string `json:"name"` diff --git a/pkg/types/node_view.go b/pkg/types/node_view.go index 062e3c0c..bd1c66d3 100644 --- a/pkg/types/node_view.go +++ b/pkg/types/node_view.go @@ -2,6 +2,8 @@ package types import ( "encoding/json" + "strings" + "time" "github.com/jmpsec/osctrl/pkg/nodes" ) @@ -93,6 +95,17 @@ type NodeEnrichment struct { Osquery *OsqueryRuntime `json:"osquery,omitempty"` } +// NodeUptime is optional node metadata populated from the latest +// osctrl:posture:uptime result when posture collection is enabled. +type NodeUptime struct { + Days int `json:"days"` + Hours int `json:"hours"` + Minutes int `json:"minutes"` + Seconds int `json:"seconds"` + TotalSeconds int64 `json:"total_seconds,omitempty"` + LastSeen time.Time `json:"last_seen,omitempty"` +} + // NodeView is the JSON shape returned by the node show + list endpoints. // It embeds OsqueryNode verbatim (so existing JSON fields stay) and adds the // optional enrichment block. Consumers that don't care about the enrichment @@ -100,9 +113,10 @@ type NodeEnrichment struct { // from it directly. type NodeView struct { nodes.OsqueryNode - NodeKey string `json:"node_key,omitempty"` - Enrichment *NodeEnrichment `json:"system_info,omitempty"` - CountryCode string `json:"country_code,omitempty"` + NodeKey string `json:"node_key,omitempty"` + Enrichment *NodeEnrichment `json:"system_info,omitempty"` + CountryCode string `json:"country_code,omitempty"` + Uptime *NodeUptime `json:"uptime,omitempty"` } // ProjectNode wraps a single OsqueryNode into the SPA-facing NodeView, parsing @@ -115,7 +129,15 @@ func ProjectNode(n nodes.OsqueryNode) NodeView { } func ProjectNodeWithCountry(n nodes.OsqueryNode, countryCode string) NodeView { - view := NodeView{OsqueryNode: n} + return ProjectNodeWithCountryAndUptime(n, countryCode, nil) +} + +func ProjectNodeWithUptime(n nodes.OsqueryNode, uptime *NodeUptime) NodeView { + return ProjectNodeWithCountryAndUptime(n, "", uptime) +} + +func ProjectNodeWithCountryAndUptime(n nodes.OsqueryNode, countryCode string, uptime *NodeUptime) NodeView { + view := NodeView{OsqueryNode: n, Uptime: uptime} if countryCode != "" { view.CountryCode = countryCode } @@ -218,3 +240,19 @@ func ProjectNodesWithCountry(in []nodes.OsqueryNode, lookup func(ip string) stri } return out } + +func ProjectNodesWithCountryAndUptime(in []nodes.OsqueryNode, lookup func(ip string) string, uptimes map[string]*NodeUptime) []NodeView { + out := make([]NodeView, len(in)) + for i, n := range in { + var cc string + if lookup != nil && n.IPAddress != "" { + cc = lookup(n.IPAddress) + } + uptime := uptimes[n.UUID] + if uptime == nil { + uptime = uptimes[strings.ToUpper(n.UUID)] + } + out[i] = ProjectNodeWithCountryAndUptime(n, cc, uptime) + } + return out +} diff --git a/pkg/types/node_view_test.go b/pkg/types/node_view_test.go index ce0353c5..48840bf5 100644 --- a/pkg/types/node_view_test.go +++ b/pkg/types/node_view_test.go @@ -46,3 +46,38 @@ func TestNodeViewMarshalsNodeKeyWhenExplicitlyAttached(t *testing.T) { t.Fatalf("projected node missing explicit node_key: %s", string(body)) } } + +func TestProjectNodeWithUptimeAddsOptionalUptime(t *testing.T) { + view := ProjectNodeWithUptime(nodes.OsqueryNode{ + ID: 1, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + UUID: "11111111-2222-3333-4444-555555555555", + Hostname: "web-01", + }, &NodeUptime{ + Days: 7, + Hours: 3, + Minutes: 12, + Seconds: 9, + TotalSeconds: 616329, + LastSeen: time.Date(2026, 7, 26, 10, 30, 0, 0, time.UTC), + }) + + body, err := json.Marshal(view) + if err != nil { + t.Fatalf("marshal: %v", err) + } + for _, want := range []string{ + `"uptime"`, + `"days":7`, + `"hours":3`, + `"minutes":12`, + `"seconds":9`, + `"total_seconds":616329`, + `"last_seen":"2026-07-26T10:30:00Z"`, + } { + if !strings.Contains(string(body), want) { + t.Fatalf("projected node missing %s: %s", want, string(body)) + } + } +}