Skip to content
Open
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 .changeset/clever-sources-disable.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@hyperdx/common-utils": patch
"@hyperdx/api": patch
"@hyperdx/app": patch
---

Add ability to disable data sources with improved UX

4 changes: 4 additions & 0 deletions packages/api/src/models/source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ export const Source = mongoose.model<ISource>(
},

name: String,
disabled: {
type: Boolean,
default: false,
},
displayedTimestampValueExpression: String,
implicitColumnExpression: String,
serviceNameExpression: String,
Expand Down
11 changes: 8 additions & 3 deletions packages/app/src/DBSearchPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -171,17 +171,22 @@ type SearchConfigFromSchema = z.infer<typeof SearchConfigSchema>;

// Helper function to get the default source id
export function getDefaultSourceId(
sources: { id: string }[] | undefined,
sources: { id: string; disabled?: boolean }[] | undefined,
lastSelectedSourceId: string | undefined,
): string {
if (!sources || sources.length === 0) return '';

// Filter out disabled sources
const enabledSources = sources.filter(s => !s.disabled);
if (enabledSources.length === 0) return '';

if (
lastSelectedSourceId &&
sources.some(s => s.id === lastSelectedSourceId)
enabledSources.some(s => s.id === lastSelectedSourceId)
) {
return lastSelectedSourceId;
}
return sources[0].id;
return enabledSources[0].id;
}

function SourceEditMenu({
Expand Down
6 changes: 4 additions & 2 deletions packages/app/src/KubernetesDashboardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -944,7 +944,8 @@ const findSource = (
s =>
(kind === undefined || s.kind === kind) &&
(id === undefined || s.id === id) &&
(connection === undefined || s.connection === connection),
(connection === undefined || s.connection === connection) &&
!s.disabled,
);
};

Expand Down Expand Up @@ -989,7 +990,8 @@ export const resolveSourceIds = (
s =>
s.kind === SourceKind.Log &&
s.metricSourceId &&
findSource(sources, { id: s.metricSourceId }),
findSource(sources, { id: s.metricSourceId }) &&
!s.disabled,
);

if (logSourceWithMetricSource) {
Expand Down
4 changes: 3 additions & 1 deletion packages/app/src/ServicesDashboardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1414,7 +1414,9 @@ function ServicesDashboardPage() {
const appliedConfigWithoutFilters = useMemo(() => {
if (!sources?.length) return appliedConfigParams;

const traceSources = sources?.filter(s => s.kind === SourceKind.Trace);
const traceSources = sources?.filter(
s => s.kind === SourceKind.Trace && !s.disabled,
);
const paramsSourceIdIsTraceSource = traceSources?.find(
s => s.id === appliedConfigParams.source,
);
Expand Down
4 changes: 2 additions & 2 deletions packages/app/src/SessionsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -257,9 +257,9 @@ export default function SessionsPage() {
// Auto-select the first session source when the page loads
useEffect(() => {
if (sources && sources.length > 0 && !appliedConfig.sessionSource) {
// Find the first session source
// Find the first enabled session source
const sessionSource = sources.find(
source => source.kind === SourceKind.Session,
source => source.kind === SourceKind.Session && !source.disabled,
);
if (sessionSource) {
setValue('source', sessionSource.id);
Expand Down
3 changes: 2 additions & 1 deletion packages/app/src/components/SourceSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,8 @@ function SourceSelectControlledComponent({
data
?.filter(
source =>
!allowedSourceKinds || allowedSourceKinds.includes(source.kind),
(!allowedSourceKinds || allowedSourceKinds.includes(source.kind)) &&
!source.disabled,
)
.map(d => ({
value: d.id,
Expand Down
54 changes: 53 additions & 1 deletion packages/app/src/components/Sources/SourceForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
Select,
Slider,
Stack,
Switch,
Text,
Tooltip,
} from '@mantine/core';
Expand Down Expand Up @@ -1453,6 +1454,37 @@ export function TableSourceForm({
}) {
const { data: source } = useSource({ id: sourceId });
const { data: connections } = useConnections();
const updateSourceMutation = useUpdateSource();

const handleDisabledToggle = useCallback(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thoughts on removing this code and just allowing the form to manage updating this value on save?

(newDisabledValue: boolean) => {
if (!source || isNew) return;

updateSourceMutation.mutate(
{
source: {
...source,
disabled: newDisabledValue,
},
},
{
onSuccess: () => {
notifications.show({
color: 'green',
message: `Source ${newDisabledValue ? 'disabled' : 'enabled'} successfully`,
});
},
onError: error => {
notifications.show({
color: 'red',
message: `Failed to ${newDisabledValue ? 'disable' : 'enable'} source - ${error.message}`,
});
},
},
);
},
[source, isNew, updateSourceMutation],
);

const { control, setValue, handleSubmit, resetField, setError, clearErrors } =
useForm<TSourceUnion>({
Expand Down Expand Up @@ -1824,7 +1856,27 @@ export function TableSourceForm({
}
>
<Stack gap="md" mb="md">
<Text mb="lg">Source Settings</Text>
<Flex justify="space-between" align="center" mb="lg">
<Text>Source Settings</Text>
{!isNew && (
<Controller
control={control}
name="disabled"
render={({ field: { value, onChange } }) => (
<Switch
size="sm"
checked={!value}
onChange={(event) => {
const newDisabledValue = !event.currentTarget.checked;
onChange(newDisabledValue);
handleDisabledToggle(newDisabledValue);
}}
label={value ? 'Disabled' : 'Enabled'}
/>
)}
/>
)}
</Flex>
<FormRow label={'Name'}>
<InputControlled
control={control}
Expand Down
59 changes: 36 additions & 23 deletions packages/app/src/components/Sources/SourcesList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -156,30 +156,43 @@ export function SourcesList({

{sources?.map((s, index) => (
<React.Fragment key={s.id}>
<Flex justify="space-between" align="center">
<div>
<Text size={textSize} fw={500}>
{s.name}
</Text>
<Text size={subtextSize} c="dimmed" mt={4}>
<Group gap="xs">
{capitalizeFirstLetter(s.kind)}
<Group gap={4}>
<IconServer size={iconSize} />
{connections?.find(c => c.id === s.connection)?.name}
</Group>
<Group gap={4}>
{s.from && (
<>
<IconStack size={iconSize} />
{s.from.databaseName}
{s.kind === SourceKind.Metric ? '' : '.'}
{s.from.tableName}
</>
)}
<Flex
justify="space-between"
align="center"
opacity={s.disabled ? 0.5 : 1}
style={{
transition: 'opacity 0.2s ease',
}}
>
<div style={{ flex: 1 }}>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@elizabetdev can you provide UX feedback here? My gut tells me this feature won't be one of the main things people are doing it, so we should move it inside the form itself (see screenshots):

Example of where to put disabled potentially
Image

Closed UI (toggle could be removed and add "Disabled" badge to details row?)
Image

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@alok87 functionality wise, things look good. pending @elizabetdev's feedback we could simplify some form code, lets see what she says :)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Also made the disabled one less highlighted.
image

image

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@alok87 The enabled/disabled toggle placement looks good to me 👍 Since it’s a high-level state, I agree it shouldn’t be buried under “Optional Fields.”

For the disabled state in the list view, the reduced opacity works well to show the status. One small thing to watch out for is the expand or collapse arrow (chevron). It would be good to keep that at full opacity.

If the arrow dims along with the text, the row can feel unclickable or read-only. Keeping the arrow bright helps signal that the row is still interactive and can be opened to update settings or re-enable it.

<Flex align="center" gap="sm">
<div>
<Group gap="xs" align="center">
<Text size={textSize} fw={500}>
{s.name}
</Text>
</Group>
</Group>
</Text>
<Text size={subtextSize} c="dimmed" mt={4}>
<Group gap="xs">
{capitalizeFirstLetter(s.kind)}
<Group gap={4}>
<IconServer size={iconSize} />
{connections?.find(c => c.id === s.connection)?.name}
</Group>
<Group gap={4}>
{s.from && (
<>
<IconStack size={iconSize} />
{s.from.databaseName}
{s.kind === SourceKind.Metric ? '' : '.'}
{s.from.tableName}
</>
)}
</Group>
</Group>
</Text>
</div>
</Flex>
</div>
<ActionIcon
variant="secondary"
Expand Down
32 changes: 19 additions & 13 deletions packages/app/src/source.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
// TODO: HDX-1768 Change TSource here to TSourceUnion and adjust as needed. Then, go to
// SourceForm.tsx and remove type assertions for TSource and TSourceUnion
import pick from 'lodash/pick';
import objectHash from 'object-hash';
import store from 'store2';
import {
ColumnMeta,
extractColumnReferencesFromKey,
Expand All @@ -20,10 +17,12 @@ import {
TSourceUnion,
} from '@hyperdx/common-utils/dist/types';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import pick from 'lodash/pick';
import objectHash from 'object-hash';
import store from 'store2';

import { hdxServer } from '@/api';
import { HDX_LOCAL_DEFAULT_SOURCES } from '@/config';
import { IS_LOCAL_MODE } from '@/config';
import { HDX_LOCAL_DEFAULT_SOURCES, IS_LOCAL_MODE } from '@/config';
import { getMetadata } from '@/metadata';
import { parseJSON } from '@/utils';

Expand Down Expand Up @@ -136,22 +135,29 @@ export function useUpdateSource() {
return useMutation({
mutationFn: async ({ source }: { source: TSource }) => {
if (IS_LOCAL_MODE) {
setLocalSources(prev => {
return prev.map(s => {
if (s.id === source.id) {
return source;
}
return s;
});
const updatedSources = getLocalSources().map(s => {
if (s.id === source.id) {
return source;
}
return s;
});
setLocalSources(updatedSources);
return source;
} else {
return await hdxServer(`sources/${source.id}`, {
method: 'PUT',
json: source,
});
}
},
onSuccess: () => {
onSuccess: (data, variables) => {
if (IS_LOCAL_MODE) {
// Directly update the cache with the new data
queryClient.setQueryData(['sources'], (oldData: TSource[] | undefined) => {
if (!oldData) return oldData;
return oldData.map(s => s.id === variables.source.id ? variables.source : s);
});
}
queryClient.invalidateQueries({ queryKey: ['sources'] });
},
});
Expand Down
1 change: 1 addition & 0 deletions packages/common-utils/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -612,6 +612,7 @@ const SourceBaseSchema = z.object({
databaseName: z.string().min(1, 'Database is required'),
tableName: z.string().min(1, 'Table is required'),
}),
disabled: z.boolean().optional(),
});

const RequiredTimestampColumnSchema = z
Expand Down