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
8 changes: 5 additions & 3 deletions cmd/api/handlers/nodes.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
}
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions frontend/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
12 changes: 12 additions & 0 deletions frontend/src/components/data/StatusPip.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<StatusPip variant="success" live />);

expect(screen.getByRole('img', { name: 'active' })).toHaveClass('pip-live');
expect(container.querySelectorAll('.pip-live-ring')).toHaveLength(1);
});
});
6 changes: 5 additions & 1 deletion frontend/src/components/data/StatusPip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ export function StatusPip({ variant, live = false, className }: StatusPipProps)
live && 'pip-live',
className,
)}
/>
>
{live && (
<span aria-hidden className="pip-live-ring" />
)}
</span>
);
}
40 changes: 40 additions & 0 deletions frontend/src/features/nodes/NodeDetailPage.merge.test.ts
Original file line number Diff line number Diff line change
@@ -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]);
});
});
33 changes: 33 additions & 0 deletions frontend/src/features/nodes/NodeDetailPage.test.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -70,6 +71,7 @@ function makeNode(overrides: Partial<OsqueryNode> = {}): 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',
Expand Down Expand Up @@ -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(
Expand All @@ -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();
});
});
85 changes: 72 additions & 13 deletions frontend/src/features/nodes/NodeDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ function HeroStrip({ node, isActive }: HeroStripProps) {
label: 'Status',
value: (
<span className="inline-flex items-center gap-1.5">
<StatusPip variant={isActive ? 'success' : 'dim'} />
<StatusPip variant={isActive ? 'success' : 'dim'} live={isActive} />
<span
className={cn(
'text-xs font-medium',
Expand Down Expand Up @@ -520,6 +520,8 @@ export function NodeDetailPage() {
const navigate = useNavigate();
const [activeTab, setActiveTab] = useState<Tab>('details');
const [activityInterval, setActivityInterval] = useState<ActivityInterval>('6h');
const [copiedNodeKey, setCopiedNodeKey] = useState(false);
const [copyNodeKeyError, setCopyNodeKeyError] = useState<string | null>(null);
const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);

function handleTabKeyDown(e: React.KeyboardEvent) {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -689,10 +707,6 @@ export function NodeDetailPage() {
</div>
) : node ? (
<>
<StatusPip
variant={isActive ? 'success' : 'dim'}
className="mt-1.5"
/>
<div className="flex-1 min-w-0">
<h1 className="font-display text-2xl font-bold text-[color:var(--text-1)] leading-tight">
{node.hostname}
Expand All @@ -701,11 +715,41 @@ export function NodeDetailPage() {
{node.uuid}
</p>
</div>
{/* 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. */}
<div className="flex items-center gap-2 flex-shrink-0">
{node.node_key && (
<button
type="button"
aria-label="Copy node key"
onClick={handleCopyNodeKey}
className={cn(
'px-3 py-1.5 text-xs font-medium rounded',
'border border-[color:var(--border)] text-[color:var(--text-2)]',
'hover:bg-[color:var(--bg-2)] hover:text-[color:var(--text-1)]',
'transition-colors',
'focus-visible:outline focus-visible:outline-2 focus-visible:outline-[color:var(--signal)]',
)}
>
{copiedNodeKey ? 'Copied node key' : 'Copy node key'}
</button>
)}
<button
type="button"
aria-label="Refresh node"
onClick={handleRefresh}
className={cn(
'px-3 py-1.5 text-xs font-medium rounded',
'border border-[color:var(--border)] text-[color:var(--text-2)]',
'hover:bg-[color:var(--bg-2)] hover:text-[color:var(--text-1)]',
'transition-colors',
'focus-visible:outline focus-visible:outline-2 focus-visible:outline-[color:var(--signal)]',
)}
>
Refresh
</button>
{/* 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
Expand Down Expand Up @@ -742,6 +786,14 @@ export function NodeDetailPage() {
{actionError}
</div>
)}
{copyNodeKeyError && (
<div
role="alert"
className="mt-2 w-full text-xs text-[color:var(--danger)] font-mono-tabular"
>
{copyNodeKeyError}
</div>
)}
</>
) : isError ? (
<EmptyState
Expand Down Expand Up @@ -1067,43 +1119,50 @@ function nodeFormatHHMM(iso: string): string {
return `${h}:${m}`;
}

// mergeNodeActivityBuckets aligns the hourly Redis config series onto the
// DB-backed activity grid (status/result/query). The DB grid uses a window-
// scaled bucket so the heatmap always has 24 columns; config (hourly) is
// folded into each cell by summing every Redis hour that overlaps the cell:
// mergeNodeActivityBuckets aligns the hourly Redis config + query read/write
// series onto the DB-backed activity grid (status/result/query). The DB grid
// uses a window-scaled bucket so the heatmap always has 24 columns; the Redis
// hourly series are folded into each cell by summing every hour that overlaps
// the cell:
// - sub-hourly cells (6h/12h) → one hour overlaps, so the hour's count is
// held across the cell (config fetches are continuous, so showing the
// hour's activity in each of its sub-cells reads as "active this hour");
// - hourly+ cells (1d and up) → counts are summed, which is honest coarsening.
// Hours outside the Redis window read as 0 — config history only exists going
// forward from when osctrl-tls started emitting activity events.
function mergeNodeActivityBuckets(
export function mergeNodeActivityBuckets(
buckets: NodeActivityBucket[],
tiles?: NodeTileSeries,
): NodeHeatmapBucket[] {
const startMs = tiles ? Date.parse(tiles.start) : NaN;
const hourMs = (tiles?.bucket_seconds ?? 3600) * 1000;
const config = tiles?.config;
const queryRead = tiles?.query_read;
const queryWrite = tiles?.query_write;
const cellMs =
buckets.length >= 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;
}
}
return {
bucket_start: b.bucket_start,
status: b.status,
result: b.result,
query: b.query,
query: queryCount,
config: configCount,
};
});
Expand Down
12 changes: 11 additions & 1 deletion frontend/src/styles/base.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand All @@ -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;
}
Expand Down
1 change: 1 addition & 0 deletions pkg/types/node_view.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}

Expand Down
Loading
Loading