From 94bdc8f328641315e4bdc750a143d41e73b273d0 Mon Sep 17 00:00:00 2001 From: Javier Marcos <1271349+javuto@users.noreply.github.com> Date: Thu, 2 Jul 2026 21:18:11 +0200 Subject: [PATCH] Changes to node details view and surface node_key for admins --- cmd/api/handlers/nodes.go | 8 +- frontend/src/api/types.ts | 1 + .../src/components/data/StatusPip.test.tsx | 12 +++ frontend/src/components/data/StatusPip.tsx | 6 +- .../nodes/NodeDetailPage.merge.test.ts | 40 +++++++++ .../features/nodes/NodeDetailPage.test.tsx | 33 +++++++ .../src/features/nodes/NodeDetailPage.tsx | 85 ++++++++++++++++--- frontend/src/styles/base.css | 12 ++- pkg/types/node_view.go | 1 + pkg/types/node_view_test.go | 48 +++++++++++ 10 files changed, 228 insertions(+), 18 deletions(-) create mode 100644 frontend/src/components/data/StatusPip.test.tsx create mode 100644 frontend/src/features/nodes/NodeDetailPage.merge.test.ts create mode 100644 pkg/types/node_view_test.go diff --git a/cmd/api/handlers/nodes.go b/cmd/api/handlers/nodes.go index 5cd36c1b..6e7aec77 100644 --- a/cmd/api/handlers/nodes.go +++ b/cmd/api/handlers/nodes.go @@ -17,7 +17,7 @@ import ( // NodeHandler - GET Handler for single JSON nodes // @Summary Get node -// @Description Returns a single enrolled node in an environment. +// @Description Returns a single enrolled node in an environment, including admin-only detail fields. // @Tags nodes // @Produce json // @Param env path string true "Environment name or UUID" @@ -52,7 +52,7 @@ func (h *HandlersApi) NodeHandler(w http.ResponseWriter, r *http.Request) { } // Get context data and check access ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) - if !h.Users.CheckPermissions(ctx[ctxUser], users.UserLevel, env.UUID) { + if !h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, env.UUID) { apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) return } @@ -78,7 +78,9 @@ 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. - utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, types.ProjectNode(node)) + view := types.ProjectNode(node) + view.NodeKey = node.NodeKey + utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, view) } // ActiveNodesHandler - GET Handler for active JSON nodes diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index e4a3d69b..5385ed23 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -68,6 +68,7 @@ export interface OsqueryNode { id: number; created_at: string; updated_at: string; + node_key?: string; uuid: string; platform: string; platform_version: string; diff --git a/frontend/src/components/data/StatusPip.test.tsx b/frontend/src/components/data/StatusPip.test.tsx new file mode 100644 index 00000000..27f3415a --- /dev/null +++ b/frontend/src/components/data/StatusPip.test.tsx @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { StatusPip } from './StatusPip'; + +describe('StatusPip', () => { + it('renders a single live activity beacon when live', () => { + const { container } = render(); + + expect(screen.getByRole('img', { name: 'active' })).toHaveClass('pip-live'); + expect(container.querySelectorAll('.pip-live-ring')).toHaveLength(1); + }); +}); diff --git a/frontend/src/components/data/StatusPip.tsx b/frontend/src/components/data/StatusPip.tsx index 97b22737..d3d3bb82 100644 --- a/frontend/src/components/data/StatusPip.tsx +++ b/frontend/src/components/data/StatusPip.tsx @@ -37,6 +37,10 @@ export function StatusPip({ variant, live = false, className }: StatusPipProps) live && 'pip-live', className, )} - /> + > + {live && ( + + )} + ); } diff --git a/frontend/src/features/nodes/NodeDetailPage.merge.test.ts b/frontend/src/features/nodes/NodeDetailPage.merge.test.ts new file mode 100644 index 00000000..baa0a14e --- /dev/null +++ b/frontend/src/features/nodes/NodeDetailPage.merge.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import type { NodeActivityBucket, NodeTileSeries } from '$/api/stats'; +import { mergeNodeActivityBuckets } from './NodeDetailPage'; + +describe('mergeNodeActivityBuckets', () => { + it('folds query read and write tile activity into the query heatmap row', () => { + const buckets: NodeActivityBucket[] = [ + { + bucket_start: '2026-07-02T10:00:00Z', + status: 0, + result: 0, + query: 0, + carve: 0, + }, + { + bucket_start: '2026-07-02T11:00:00Z', + status: 0, + result: 0, + query: 0, + carve: 0, + }, + ]; + + const tiles: NodeTileSeries = { + start: '2026-07-02T10:00:00Z', + bucket_seconds: 3600, + enroll: [0, 0], + config: [0, 0], + status: [0, 0], + result: [0, 0], + query_read: [2, 0], + query_write: [0, 3], + total: [2, 3], + }; + + const merged = mergeNodeActivityBuckets(buckets, tiles); + + expect(merged.map((b) => b.query)).toEqual([2, 3]); + }); +}); diff --git a/frontend/src/features/nodes/NodeDetailPage.test.tsx b/frontend/src/features/nodes/NodeDetailPage.test.tsx index c1147ede..85ee2991 100644 --- a/frontend/src/features/nodes/NodeDetailPage.test.tsx +++ b/frontend/src/features/nodes/NodeDetailPage.test.tsx @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { createMemoryHistory, @@ -70,6 +71,7 @@ function makeNode(overrides: Partial = {}): OsqueryNode { id: 1, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z', + node_key: 'node-key-123', uuid: 'abc12345-0000-0000-0000-000000000001', platform: 'linux', platform_version: '22.04', @@ -205,6 +207,9 @@ describe('NodeDetailPage', () => { expect(screen.queryByRole('tab', { name: 'Activity' })).not.toBeInTheDocument(); expect(screen.getByRole('tab', { name: 'Details' })).toHaveAttribute('aria-selected', 'true'); expect(screen.getByRole('heading', { name: /Node activity/i })).toBeInTheDocument(); + const activePips = screen.getAllByRole('img', { name: 'active' }); + expect(activePips).toHaveLength(1); + expect(activePips[0]).toHaveClass('pip-live'); await waitFor(() => { expect(mockGetNodeActivity).toHaveBeenCalledWith( @@ -215,4 +220,32 @@ describe('NodeDetailPage', () => { ); }); }); + + it('copies the node key and refreshes the node view', async () => { + const user = userEvent.setup(); + const writeText = vi.fn().mockResolvedValue(undefined); + vi.stubGlobal('navigator', { + ...navigator, + clipboard: { writeText }, + }); + + renderWithProviders(makeTestRouter()); + + await waitFor(() => { + expect(screen.getByRole('heading', { name: 'web-server-01' })).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('button', { name: 'Copy node key' })); + + expect(writeText).toHaveBeenCalledWith('node-key-123'); + expect(screen.getByText('Copied node key')).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Refresh node' })); + + await waitFor(() => { + expect(mockGetNode).toHaveBeenCalledTimes(2); + }); + + vi.unstubAllGlobals(); + }); }); diff --git a/frontend/src/features/nodes/NodeDetailPage.tsx b/frontend/src/features/nodes/NodeDetailPage.tsx index d1996f62..fbb899f7 100644 --- a/frontend/src/features/nodes/NodeDetailPage.tsx +++ b/frontend/src/features/nodes/NodeDetailPage.tsx @@ -137,7 +137,7 @@ function HeroStrip({ node, isActive }: HeroStripProps) { label: 'Status', value: ( - + ('details'); const [activityInterval, setActivityInterval] = useState('6h'); + const [copiedNodeKey, setCopiedNodeKey] = useState(false); + const [copyNodeKeyError, setCopyNodeKeyError] = useState(null); const tabRefs = useRef<(HTMLButtonElement | null)[]>([]); function handleTabKeyDown(e: React.KeyboardEvent) { @@ -589,6 +591,22 @@ export function NodeDetailPage() { archiveMut.mutate(); } + async function handleCopyNodeKey() { + if (!node?.node_key) return; + try { + await navigator.clipboard.writeText(node.node_key); + setCopiedNodeKey(true); + setCopyNodeKeyError(null); + setTimeout(() => setCopiedNodeKey(false), 1500); + } catch { + setCopyNodeKeyError('Clipboard blocked by browser'); + } + } + + function handleRefresh() { + void qc.invalidateQueries({ queryKey: ['node', env, uuid] }); + } + // Node-scoped activity heatmap — now embedded in the default Details view. // Keep the polling scoped to the Details tab so switching into raw log views // does not leave a background refresh loop running for an off-screen chart. @@ -689,10 +707,6 @@ export function NodeDetailPage() { ) : node ? ( <> -

{node.hostname} @@ -701,11 +715,41 @@ export function NodeDetailPage() { {node.uuid}

- {/* Single-node action toolbar — Archive + Refresh + Delete. + {/* Single-node action toolbar — copy node_key, refresh, archive. Archive routes through the same DELETE endpoint with archive=true, which the server gates on env-admin — so the button hides for non-admins same as Delete. */}
+ {node.node_key && ( + + )} + {/* Single archive action — no separate hard-delete button. Archive snapshots the node into archive_osquery_nodes before removing the live row, so every removal is @@ -742,6 +786,14 @@ export function NodeDetailPage() { {actionError}
)} + {copyNodeKeyError && ( +
+ {copyNodeKeyError} +
+ )} ) : isError ? ( = 2 ? Date.parse(buckets[1].bucket_start) - Date.parse(buckets[0].bucket_start) : 3600_000; return buckets.map((b) => { let configCount = 0; + let queryCount = b.query; if (config && config.length > 0 && !Number.isNaN(startMs)) { const cellStart = Date.parse(b.bucket_start); const cellEnd = cellStart + cellMs; let h = Math.floor((cellStart - startMs) / hourMs); while (h < config.length && startMs + h * hourMs < cellEnd) { - if (h >= 0) configCount += config[h]; + if (h >= 0) { + configCount += config[h]; + queryCount += (queryRead?.[h] ?? 0) + (queryWrite?.[h] ?? 0); + } h += 1; } } @@ -1103,7 +1162,7 @@ function mergeNodeActivityBuckets( bucket_start: b.bucket_start, status: b.status, result: b.result, - query: b.query, + query: queryCount, config: configCount, }; }); diff --git a/frontend/src/styles/base.css b/frontend/src/styles/base.css index e09c6e55..0574e44c 100644 --- a/frontend/src/styles/base.css +++ b/frontend/src/styles/base.css @@ -138,6 +138,16 @@ body { 100% { transform: scale(2.2); opacity: 0; } } +.pip-live-ring { + position: absolute; + inset: -3px; + border-radius: 9999px; + background: inherit; + opacity: 0; + animation: pip-pulse 1.8s ease-out infinite; + pointer-events: none; +} + /* --- Stale-data shimmer (background refetch in flight) --- */ @keyframes stale-shimmer { 0% { opacity: 1; } @@ -150,7 +160,7 @@ tbody[data-stale="true"] { } @media (prefers-reduced-motion: reduce) { - .pip-live::after, + .pip-live-ring, tbody[data-stale="true"] { animation: none; } diff --git a/pkg/types/node_view.go b/pkg/types/node_view.go index fc313555..00fb278b 100644 --- a/pkg/types/node_view.go +++ b/pkg/types/node_view.go @@ -100,6 +100,7 @@ type NodeEnrichment struct { // from it directly. type NodeView struct { nodes.OsqueryNode + NodeKey string `json:"node_key,omitempty"` Enrichment *NodeEnrichment `json:"system_info,omitempty"` } diff --git a/pkg/types/node_view_test.go b/pkg/types/node_view_test.go new file mode 100644 index 00000000..ce0353c5 --- /dev/null +++ b/pkg/types/node_view_test.go @@ -0,0 +1,48 @@ +package types + +import ( + "encoding/json" + "strings" + "testing" + "time" + + "github.com/jmpsec/osctrl/pkg/nodes" +) + +func TestProjectNodeOmitsNodeKeyByDefault(t *testing.T) { + view := ProjectNode(nodes.OsqueryNode{ + ID: 1, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + NodeKey: "secret-node-key", + UUID: "11111111-2222-3333-4444-555555555555", + Hostname: "web-01", + }) + + body, err := json.Marshal(view) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if strings.Contains(string(body), "secret-node-key") { + t.Fatalf("projected node leaked node_key: %s", string(body)) + } +} + +func TestNodeViewMarshalsNodeKeyWhenExplicitlyAttached(t *testing.T) { + view := ProjectNode(nodes.OsqueryNode{ + ID: 1, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + UUID: "11111111-2222-3333-4444-555555555555", + Hostname: "web-01", + }) + view.NodeKey = "secret-node-key" + + body, err := json.Marshal(view) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if !strings.Contains(string(body), `"node_key":"secret-node-key"`) { + t.Fatalf("projected node missing explicit node_key: %s", string(body)) + } +}