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
44 changes: 36 additions & 8 deletions cmd/api/handlers/nodes.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package handlers

import (
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
Expand All @@ -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
Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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.
Expand Down
55 changes: 55 additions & 0 deletions cmd/api/handlers/nodes_posture_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
11 changes: 11 additions & 0 deletions frontend/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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';
Expand Down
50 changes: 50 additions & 0 deletions frontend/src/features/nodes/NodeDetailPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
25 changes: 24 additions & 1 deletion frontend/src/features/nodes/NodeDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -1106,6 +1116,19 @@ export function NodeDetailPage() {
<KvGrid
title="Lifecycle"
items={[
...(postureEnabled ? [
{
label: 'Uptime',
value: (
<span
className="font-mono-tabular tnum text-xs"
title={node.uptime?.last_seen ? `Collected ${formatAbsolute(node.uptime.last_seen)}` : undefined}
>
{formatNodeUptime(node.uptime)}
</span>
),
},
] : []),
{
label: 'First seen',
value: (
Expand Down
Loading
Loading