mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
feat: Improve local-mode deeplinking (#2080)
## Summary When `IS_LOCAL_MODE` sources are created (e.g. via "Connect to Demo Server" in the onboarding modal), IDs are now derived from a stable hash of the item content instead of `Math.random()`. This ensures every user who connects to the same demo server gets the same source IDs, making deep links with `?source=<id>` work across different users and sessions. This has the added benefit of ensuring that the dashboards created in local mode will not break due to new sources being created on every new session. Further, the `<OnboardingModal>` has been added to a few pages where it was previous missing, ensuring that users which are deeplinked to those pages see the modal and are able to create the demo connection. ### How to test locally or on Vercel 1. Open the play/demo environment in two separate browser profiles (or incognito windows) so each has fresh localStorage 2. In the first, connect to the demo server, then navigate to a page where a source is selected 3. Copy the link into the second browser. You should see the "connect to demo server" and once connected you should see the same source as was selected in the 1st browser. ### References - Linear Issue: Closes HDX-3974 - Related PRs: none
This commit is contained in:
parent
f8f7634552
commit
0daa52993c
6 changed files with 92 additions and 6 deletions
5
.changeset/young-cameras-doubt.md
Normal file
5
.changeset/young-cameras-doubt.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@hyperdx/app": patch
|
||||
---
|
||||
|
||||
feat: Generate stable source IDs in local mode
|
||||
|
|
@ -35,6 +35,8 @@ import { useBrandDisplayName } from '@/theme/ThemeProvider';
|
|||
import { parseTimeQuery, useNewTimeQuery } from '@/timeQuery';
|
||||
import { useLocalStorage } from '@/utils';
|
||||
|
||||
import OnboardingModal from './components/OnboardingModal';
|
||||
|
||||
// Autocomplete can focus on column/map keys
|
||||
|
||||
// Sampled field discovery and full field discovery
|
||||
|
|
@ -235,6 +237,7 @@ function DBChartExplorerPage() {
|
|||
<Head>
|
||||
<title>Chart Explorer - {brandName}</title>
|
||||
</Head>
|
||||
<OnboardingModal />
|
||||
<AIAssistant
|
||||
setConfig={setChartConfig}
|
||||
onTimeRangeSelect={onTimeRangeSelect}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
import Head from 'next/head';
|
||||
import { parseAsInteger, useQueryState } from 'nuqs';
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
import { SourceKind, TTraceSource } from '@hyperdx/common-utils/dist/types';
|
||||
|
|
@ -10,11 +11,13 @@ import EmptyState from '@/components/EmptyState';
|
|||
import { IS_LOCAL_MODE } from '@/config';
|
||||
import { withAppNav } from '@/layout';
|
||||
|
||||
import OnboardingModal from './components/OnboardingModal';
|
||||
import ServiceMap from './components/ServiceMap/ServiceMap';
|
||||
import { TableSourceForm } from './components/Sources/SourceForm';
|
||||
import SourceSchemaPreview from './components/SourceSchemaPreview';
|
||||
import { SourceSelectControlled } from './components/SourceSelect';
|
||||
import { TimePicker } from './components/TimePicker';
|
||||
import { useBrandDisplayName } from './theme/ThemeProvider';
|
||||
import { useSources } from './source';
|
||||
import { parseTimeQuery, useNewTimeQuery } from './timeQuery';
|
||||
|
||||
|
|
@ -49,6 +52,8 @@ const defaultTimeRange = parseTimeQuery(DEFAULT_INTERVAL, false) as [
|
|||
];
|
||||
|
||||
function DBServiceMapPage() {
|
||||
const brandName = useBrandDisplayName();
|
||||
|
||||
const { data: sources } = useSources();
|
||||
const [sourceId, setSourceId] = useQueryState('source');
|
||||
const [isCreateSourceModalOpen, setIsCreateSourceModalOpen] = useState(false);
|
||||
|
|
@ -97,6 +102,18 @@ function DBServiceMapPage() {
|
|||
const hasTraceSources = sources != null && defaultSource != null;
|
||||
const isLoading = sources == null;
|
||||
|
||||
const head = useMemo(
|
||||
() => (
|
||||
<>
|
||||
<Head>
|
||||
<title>Service Map - {brandName}</title>
|
||||
</Head>
|
||||
<OnboardingModal />
|
||||
</>
|
||||
),
|
||||
[brandName],
|
||||
);
|
||||
|
||||
if (!isLoading && !hasTraceSources) {
|
||||
return (
|
||||
<Box
|
||||
|
|
@ -104,6 +121,7 @@ function DBServiceMapPage() {
|
|||
className="bg-body"
|
||||
style={{ display: 'flex', flexDirection: 'column', height: '100vh' }}
|
||||
>
|
||||
{head}
|
||||
<Text size="xl" mb="md">
|
||||
Service Map
|
||||
</Text>
|
||||
|
|
@ -160,6 +178,7 @@ function DBServiceMapPage() {
|
|||
className="bg-body"
|
||||
style={{ display: 'flex', flexDirection: 'column', height: '100vh' }}
|
||||
>
|
||||
{head}
|
||||
<Group mb="md" justify="space-between">
|
||||
<Group>
|
||||
<Text size="xl">Service Map</Text>
|
||||
|
|
|
|||
|
|
@ -12,12 +12,10 @@ import {
|
|||
SourceKind,
|
||||
} from '@hyperdx/common-utils/dist/types';
|
||||
import {
|
||||
Alert,
|
||||
Anchor,
|
||||
Box,
|
||||
Button,
|
||||
Code,
|
||||
Divider,
|
||||
Flex,
|
||||
Group,
|
||||
Paper,
|
||||
|
|
@ -25,7 +23,6 @@ import {
|
|||
} from '@mantine/core';
|
||||
import {
|
||||
IconDeviceLaptop,
|
||||
IconInfoCircleFilled,
|
||||
IconPlayerPlay,
|
||||
IconRefresh,
|
||||
} from '@tabler/icons-react';
|
||||
|
|
@ -36,6 +33,7 @@ import { SourceSelectControlled } from '@/components/SourceSelect';
|
|||
import { TimePicker } from '@/components/TimePicker';
|
||||
import { parseTimeQuery, useNewTimeQuery } from '@/timeQuery';
|
||||
|
||||
import OnboardingModal from './components/OnboardingModal';
|
||||
import SearchWhereInput, {
|
||||
getStoredLanguage,
|
||||
} from './components/SearchInput/SearchWhereInput';
|
||||
|
|
@ -394,6 +392,7 @@ export default function SessionsPage() {
|
|||
<Head>
|
||||
<title>Client Sessions - {brandName}</title>
|
||||
</Head>
|
||||
<OnboardingModal />
|
||||
{selectedSession != null &&
|
||||
traceTrace != null &&
|
||||
sessionSource != null &&
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ jest.mock('../utils', () => ({
|
|||
|
||||
import {
|
||||
createEntityStore,
|
||||
generateDeterministicId,
|
||||
localSavedSearches,
|
||||
localSources,
|
||||
} from '../localStore';
|
||||
|
|
@ -104,6 +105,56 @@ describe('createEntityStore', () => {
|
|||
|
||||
expect(store.getAll()).toHaveLength(3);
|
||||
});
|
||||
|
||||
describe('with generateDeterministicId', () => {
|
||||
it('generates deterministic ids from item content', () => {
|
||||
const storeA = createEntityStore<Item>(
|
||||
'store-det-a',
|
||||
undefined,
|
||||
generateDeterministicId,
|
||||
);
|
||||
const storeB = createEntityStore<Item>(
|
||||
'store-det-b',
|
||||
undefined,
|
||||
generateDeterministicId,
|
||||
);
|
||||
|
||||
const a = storeA.create({ name: 'demo-source' });
|
||||
const b = storeB.create({ name: 'demo-source' });
|
||||
|
||||
expect(a.id).toBe(b.id);
|
||||
});
|
||||
|
||||
it('generates the same id regardless of property insertion order', () => {
|
||||
type MultiProp = { id: string; name: string; kind: string };
|
||||
const storeA = createEntityStore<MultiProp>(
|
||||
'store-ord-a',
|
||||
undefined,
|
||||
generateDeterministicId,
|
||||
);
|
||||
const storeB = createEntityStore<MultiProp>(
|
||||
'store-ord-b',
|
||||
undefined,
|
||||
generateDeterministicId,
|
||||
);
|
||||
|
||||
const a = storeA.create({ name: 'demo', kind: 'log' });
|
||||
const b = storeB.create({ kind: 'log', name: 'demo' });
|
||||
|
||||
expect(a.id).toBe(b.id);
|
||||
});
|
||||
|
||||
it('generates different ids for items with different content', () => {
|
||||
const store = createEntityStore<Item>(
|
||||
TEST_KEY,
|
||||
undefined,
|
||||
generateDeterministicId,
|
||||
);
|
||||
const a = store.create({ name: 'alpha' });
|
||||
const b = store.create({ name: 'beta' });
|
||||
expect(a.id).not.toBe(b.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import objectHash from 'object-hash';
|
||||
import store from 'store2';
|
||||
import { hashCode } from '@hyperdx/common-utils/dist/core/utils';
|
||||
import {
|
||||
SavedSearchListApiResponse,
|
||||
TSource,
|
||||
|
|
@ -10,6 +10,12 @@ import { parseJSON } from './utils';
|
|||
|
||||
type EntityWithId = { id: string };
|
||||
|
||||
const generateRandomId = () =>
|
||||
objectHash({ random: Math.random() }).slice(0, 16);
|
||||
|
||||
export const generateDeterministicId = (item: object) =>
|
||||
objectHash(item).slice(0, 16);
|
||||
|
||||
/**
|
||||
* Generic localStorage CRUD store for local-mode entities.
|
||||
* Uses store2 for atomic transact operations and JSON serialization.
|
||||
|
|
@ -17,6 +23,7 @@ type EntityWithId = { id: string };
|
|||
export function createEntityStore<T extends EntityWithId>(
|
||||
key: string,
|
||||
getDefaultItems?: () => T[],
|
||||
generateObjectId: (item: Omit<T, 'id'>) => string = generateRandomId,
|
||||
) {
|
||||
function getAll(): T[] {
|
||||
if (getDefaultItems != null && !store.has(key)) {
|
||||
|
|
@ -31,7 +38,7 @@ export function createEntityStore<T extends EntityWithId>(
|
|||
create(item: Omit<T, 'id'>): T {
|
||||
const newItem = {
|
||||
...item,
|
||||
id: Math.abs(hashCode(Math.random().toString())).toString(16),
|
||||
id: generateObjectId(item),
|
||||
} as T;
|
||||
// Seed transact from defaults when the key is absent so that
|
||||
// env-var-seeded items are not silently dropped on the first write.
|
||||
|
|
@ -96,6 +103,8 @@ export const localSources = createEntityStore<TSource>(
|
|||
}
|
||||
return [];
|
||||
},
|
||||
// Make the id deterministic so that local-mode source IDs remain stable across users, for easy local-mode sharing
|
||||
generateDeterministicId,
|
||||
);
|
||||
|
||||
/** Saved searches store (alerts remain cloud-only; no alert fields persisted locally). */
|
||||
|
|
|
|||
Loading…
Reference in a new issue