Skip to content
Draft
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: 8 additions & 0 deletions changelog/unreleased/pr-26839.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# PLEASE REMOVE COMMENTS AND OPTIONAL FIELDS! THANKS!

# Entry type according to https://keepachangelog.com/en/1.0.0/
# One of: a(dded), c(hanged), d(eprecated), r(emoved), f(ixed), s(ecurity)
type = "a"
message = "Added Collector onboarding page"

pulls = ["26839"]
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/*
* Copyright (C) 2020 Graylog, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the Server Side Public License, version 1,
* as published by MongoDB, Inc.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* Server Side Public License for more details.
*
* You should have received a copy of the Server Side Public License
* along with this program. If not, see
* <http://www.mongodb.com/licensing/server-side-public-license>.
*/
import type { ColorVariant } from '@graylog/sawmill';

import type { CollectorInstanceView } from '../types';

type InstanceStatus = CollectorInstanceView['status'];

/**
* How a collector's online/offline state is worded and coloured. Render via `InstanceStatusLabel`
* rather than reading this directly, unless you need the raw wording (sorting, export, a title
* attribute).
*/
export const INSTANCE_STATUS_LABELS: Record<InstanceStatus, { label: string; style: ColorVariant }> = {
online: { label: 'Online', style: 'success' },
offline: { label: 'Offline', style: 'default' },
};

/**
* Human names for the operating systems a collector reports. These are the agent's own values, so
* `darwin` rather than the `macos` id the install-platform list uses. Unknown values should fall
* back to the raw string rather than being hidden.
*/
export const OS_LABELS: Record<string, string> = {
linux: 'Linux',
windows: 'Windows',
darwin: 'macOS',
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/*
* Copyright (C) 2020 Graylog, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the Server Side Public License, version 1,
* as published by MongoDB, Inc.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* Server Side Public License for more details.
*
* You should have received a copy of the Server Side Public License
* along with this program. If not, see
* <http://www.mongodb.com/licensing/server-side-public-license>.
*/
import styled, { css } from 'styled-components';

/**
* Label/value row primitives for collector detail views, shared between the instance detail drawer
* and the onboarding summary so both render identically.
*/
export const DetailRow = styled.div(
({ theme }) => css`
display: flex;
align-items: center;
gap: ${theme.spacings.xs};
margin-bottom: ${theme.spacings.xs};

/* Set here rather than on the label so a row's label and value can never disagree. The label
used to declare this on its own while values inherited the larger body size, which left
every row visibly mismatched. */
font-size: ${theme.fonts.size.small};

/* Long values (certificate hashes, attribute strings) wrap instead of overflowing a narrow
container; a flex child defaults to min-width auto and would otherwise refuse to shrink. */
> :last-child {
min-width: 0;
overflow-wrap: anywhere;
}
`,
);

/** The leading label of a {@link DetailRow}. Reserves a fixed width so values line up. */
export const DetailLabel = styled.span`
font-weight: 500;
min-width: 120px;
`;
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/*
* Copyright (C) 2020 Graylog, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the Server Side Public License, version 1,
* as published by MongoDB, Inc.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* Server Side Public License for more details.
*
* You should have received a copy of the Server Side Public License
* along with this program. If not, see
* <http://www.mongodb.com/licensing/server-side-public-license>.
*/
import * as React from 'react';

import { Label } from 'components/bootstrap';

import { INSTANCE_STATUS_LABELS } from './Constants';

import type { CollectorInstanceView } from '../types';

type Props = {
status: CollectorInstanceView['status'];
};

/**
* The collector's online/offline state. Shared between the instances table column, the instance
* detail drawer and the onboarding summary so all three render identically.
*/
const InstanceStatusLabel = ({ status }: Props) => {
// A status the frontend does not know about reads as offline, matching the previous behaviour of
// the `status === 'online'` checks this replaced.
const { label, style } = INSTANCE_STATUS_LABELS[status] ?? INSTANCE_STATUS_LABELS.offline;

return <Label bsStyle={style}>{label}</Label>;
};

export default InstanceStatusLabel;
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/*
* Copyright (C) 2020 Graylog, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the Server Side Public License, version 1,
* as published by MongoDB, Inc.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* Server Side Public License for more details.
*
* You should have received a copy of the Server Side Public License
* along with this program. If not, see
* <http://www.mongodb.com/licensing/server-side-public-license>.
*/
import { OS_LABELS } from './Constants';

import type { CollectorInstanceView } from '../types';

/**
* The collector's operating system, for display in detail views. Prefers the description the agent
* reports (the most specific, e.g. "Ubuntu 22.04.3 LTS") and otherwise translates the bare `os`
* value to a human name, keeping the raw value if it is one we do not know.
*/
const collectorOsName = (instance: CollectorInstanceView): string => {
const description = instance.non_identifying_attributes?.['os.description'] as string | undefined;

if (description) {
return description;
}

if (instance.os) {
return OS_LABELS[instance.os] ?? instance.os;
}

return 'Unknown';
};

export default collectorOsName;
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
* along with this program. If not, see
* <http://www.mongodb.com/licensing/server-side-public-license>.
*/
import { renderHook, waitFor } from 'wrappedTestingLibrary/hooks';
import { act, renderHook, waitFor } from 'wrappedTestingLibrary/hooks';
import { OrderedMap } from 'immutable';

import { Collectors } from '@graylog/server-api';
Expand Down Expand Up @@ -61,7 +61,8 @@ describe('useInstance', () => {

await waitFor(() => expect(result.current.data).toBeTruthy());

expect(Collectors.getInstance).toHaveBeenCalledWith('uid-42');
// The query polls, so it must not keep an idle user's session alive.
expect(Collectors.getInstance).toHaveBeenCalledWith('uid-42', { requestShouldExtendSession: false });

expect(result.current.data).toEqual(
expect.objectContaining({
Expand Down Expand Up @@ -91,6 +92,34 @@ describe('useInstance', () => {

expect(Collectors.getInstance).not.toHaveBeenCalled();
});

// A collector that stops reporting in is marked offline server-side, with no user interaction to
// trigger a refetch. Without polling, a page rendered while the collector was healthy keeps
// asserting "online" forever.
it('picks up a status change without any user interaction', async () => {
jest.useFakeTimers();

asMock(Collectors.getInstance).mockResolvedValue(asInstanceResponse('uid-42'));

const { result } = renderHook(() => useInstance('uid-42'));

await waitFor(() => expect(result.current.data?.status).toBe('online'));

asMock(Collectors.getInstance).mockResolvedValue({
...dto('uid-42'),
status: 'offline',
} as unknown as Awaited<ReturnType<typeof Collectors.getInstance>>);

// Past the fallback heartbeat cadence used until the collectors config has loaded. Wrapped in
// `act` because the refetch it triggers re-renders the hook outside React's own scheduling.
await act(async () => {
await jest.advanceTimersByTimeAsync(31_000);
});

await waitFor(() => expect(result.current.data?.status).toBe('offline'));

jest.useRealTimers();
});
});

describe('fetchPaginatedInstances session extension', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,14 @@ import { defaultOnError } from 'util/conditional/onError';
import type { PaginatedResponse } from 'components/common/PaginatedEntityTable/useFetchEntities';
import type { RequestOptions } from 'routing/request';

import useCollectorRefetchInterval from './useCollectorRefetchInterval';

import type { CollectorInstanceView } from '../types';

const NO_SESSION_EXT: RequestOptions = { requestShouldExtendSession: false };
export const INSTANCES_KEY_PREFIX = ['collectors', 'instances'];
export const instancesKeyFn = (searchParams: SearchParams) => [...INSTANCES_KEY_PREFIX, 'paginated', searchParams];
export const instanceKeyFn = (instanceUid: string | undefined) => [...INSTANCES_KEY_PREFIX, 'single', instanceUid];

type ApiInstanceResponse = Awaited<ReturnType<typeof Collectors.findInstances>>['elements'][number];

Expand Down Expand Up @@ -118,16 +121,29 @@ export const useInstances = (fleetId?: string, options: { refetchInterval?: numb
refetchInterval: options.refetchInterval,
});

/**
* A single instance, polled at the collector heartbeat cadence.
*
* The status and `last_seen` of a live collector change without any user interaction — a collector
* that stops reporting in is marked offline server-side — so a one-shot fetch would leave a page
* asserting "online" indefinitely. Polling is tied to the heartbeat interval because the data cannot
* become fresher than that: a shorter interval only adds requests.
*
* Two consequences of polling, both deliberate:
* - Requests do not extend the session, per the periodic-request rule. Arriving on a page still
* extends it through the sibling queries (fleet, config), which do not opt out — the same reasoning
* documented for `isBackgroundRefresh` above.
* - Errors are not reported through `defaultOnError`. A persistent failure would otherwise raise a
* toast on every poll; callers get `error`/`isError` and render the failure themselves.
*/
export const useInstance = (instanceUid: string | undefined) => {
const refetchInterval = useCollectorRefetchInterval();

const { data, isLoading, error, isError } = useQuery<CollectorInstanceView>({
queryKey: [...INSTANCES_KEY_PREFIX, 'single', instanceUid],
queryFn: () =>
defaultOnError(
Collectors.getInstance(instanceUid).then((response) => toView(response)),
'Loading Collector instance failed with status',
'Could not load Collector instance',
),
queryKey: instanceKeyFn(instanceUid),
queryFn: () => Collectors.getInstance(instanceUid, NO_SESSION_EXT).then((response) => toView(response)),
enabled: !!instanceUid,
refetchInterval,
});

return { data, isLoading, error, isError };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,24 +16,19 @@
*/
import * as React from 'react';

import { Label } from 'components/bootstrap';
import { Link, RelativeTime } from 'components/common';
import Routes from 'routing/Routes';
import type { ColumnRenderers } from 'components/common/EntityDataTable';

import InstanceStatusLabel from '../common/InstanceStatusLabel';
import SyncStateIndicator from '../common/SyncStateIndicator';
import collectorOsName from '../common/collectorOsName';
import type { CollectorInstanceView } from '../types';

const OsIcon = ({ os }: { os: string | null }) => {
if (os === 'linux') return <span title="Linux">Linux</span>;
if (os === 'windows') return <span title="Windows">Windows</span>;
if (os === 'darwin') return <span title="macOS">macOS</span>;
const OsName = ({ instance }: { instance: CollectorInstanceView }) => {
const label = collectorOsName(instance);

return (
<span title="Unknown">
<i>Unknown</i>
</span>
);
return <span title={label}>{label}</span>;
};

type Props = {
Expand All @@ -44,9 +39,7 @@ const customColumnRenderers = ({ fleetNames }: Props): ColumnRenderers<Collector
attributes: {
status: {
renderCell: (_status: string, instance: CollectorInstanceView) => (
<Label bsStyle={instance.status === 'online' ? 'success' : 'default'}>
{instance.status === 'online' ? 'Online' : 'Offline'}
</Label>
<InstanceStatusLabel status={instance.status} />
),
staticWidth: 100,
},
Expand All @@ -63,7 +56,7 @@ const customColumnRenderers = ({ fleetNames }: Props): ColumnRenderers<Collector
width: 0.3,
},
os: {
renderCell: (_os: string, instance: CollectorInstanceView) => <OsIcon os={instance.os} />,
renderCell: (_os: string, instance: CollectorInstanceView) => <OsName instance={instance} />,
staticWidth: 60,
},
fleet_id: {
Expand Down
Loading
Loading