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}