Skip to content

Commit 9778067

Browse files
[1/2] Asset Health Observe UIs phase 1 (#29151)
[INTERNAL_BRANCH=salazarm/asset-health-ui] Implements phase 1 of the asset health observe UIs as demoed at the R&D demos on Friday. ## Test Plan R&D demo <img width="161" alt="Screenshot 2025-04-09 at 1 20 21 PM" src="https://github.com/user-attachments/assets/b28636e7-f944-4e51-aed7-206559e155a1" /> <img width="1725" alt="Screenshot 2025-04-09 at 1 19 05 PM" src="https://github.com/user-attachments/assets/65df22d0-5735-4572-a56f-c86e22378092" /> <img width="1728" alt="Screenshot 2025-04-09 at 1 19 00 PM" src="https://github.com/user-attachments/assets/4f78b879-bc3f-46e2-bcce-f1adc0b77f18" /> --------- Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com>
1 parent 79f8c10 commit 9778067

30 files changed

+1527
-187
lines changed

Diff for: js_modules/dagster-ui/packages/ui-components/src/components/Icon.tsx

+6
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,7 @@ import expand_arrows from '../icon-svgs/expand_arrows.svg';
146146
import expand_less from '../icon-svgs/expand_less.svg';
147147
import expand_more from '../icon-svgs/expand_more.svg';
148148
import expectation from '../icon-svgs/expectation.svg';
149+
import failure_trend from '../icon-svgs/failure_trend.svg';
149150
import file_csv from '../icon-svgs/file_csv.svg';
150151
import file_json from '../icon-svgs/file_json.svg';
151152
import file_markdown from '../icon-svgs/file_markdown.svg';
@@ -351,6 +352,7 @@ import sticky_note from '../icon-svgs/sticky_note.svg';
351352
import storage_kind from '../icon-svgs/storage_kind.svg';
352353
import subtract from '../icon-svgs/subtract.svg';
353354
import success from '../icon-svgs/success.svg';
355+
import successful_trend from '../icon-svgs/successful_trend.svg';
354356
import sun from '../icon-svgs/sun.svg';
355357
import support from '../icon-svgs/support.svg';
356358
import sync from '../icon-svgs/sync.svg';
@@ -393,6 +395,7 @@ import visibility from '../icon-svgs/visibility.svg';
393395
import visibility_off from '../icon-svgs/visibility_off.svg';
394396
import warning from '../icon-svgs/warning.svg';
395397
import warning_outline from '../icon-svgs/warning_outline.svg';
398+
import warning_trend from '../icon-svgs/warning_trend.svg';
396399
import water from '../icon-svgs/water.svg';
397400
import waterfall_chart from '../icon-svgs/waterfall_chart.svg';
398401
import webhook from '../icon-svgs/webhook.svg';
@@ -559,6 +562,7 @@ export const Icons = {
559562
expand_less,
560563
expand_more,
561564
expectation,
565+
failure_trend,
562566
file_csv,
563567
file_json,
564568
file_markdown,
@@ -760,6 +764,7 @@ export const Icons = {
760764
storage_kind,
761765
subtract,
762766
success,
767+
successful_trend,
763768
sun,
764769
support,
765770
sync,
@@ -802,6 +807,7 @@ export const Icons = {
802807
visibility_off,
803808
warning,
804809
warning_outline,
810+
warning_trend,
805811
water,
806812
waterfall_chart,
807813
webhook,

Diff for: js_modules/dagster-ui/packages/ui-components/src/components/useDelayedState.tsx

+3-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import {useEffect, useState} from 'react';
1+
import {useDeferredValue, useEffect, useState} from 'react';
22

33
export const useDelayedState = (delayMsec: number) => {
4-
const [ready, setReady] = useState(false);
4+
const [_ready, setReady] = useState(false);
5+
const ready = useDeferredValue(_ready);
56

67
useEffect(() => {
78
const timer = setTimeout(() => setReady(true), delayMsec);
Loading
Loading
Loading

Diff for: js_modules/dagster-ui/packages/ui-core/client.json

+1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import React from 'react';
2+
3+
import {ApolloClient, gql, useApolloClient} from '../apollo-client';
4+
import {AssetBaseData} from './AssetBaseDataProvider';
5+
import {tokenForAssetKey, tokenToAssetKey} from '../asset-graph/Utils';
6+
import {AssetKeyInput} from '../graphql/types';
7+
import {liveDataFactory} from '../live-data-provider/Factory';
8+
import {LiveDataThreadID} from '../live-data-provider/LiveDataThread';
9+
import {useBlockTraceUntilTrue} from '../performance/TraceContext';
10+
import {AssetHealthQuery, AssetHealthQueryVariables} from './types/AssetHealthDataProvider.types';
11+
12+
function init() {
13+
return liveDataFactory(
14+
() => {
15+
return useApolloClient();
16+
},
17+
async (keys, client: ApolloClient<any>) => {
18+
const assetKeys = keys.map(tokenToAssetKey);
19+
const healthResponse = await client.query<AssetHealthQuery, AssetHealthQueryVariables>({
20+
query: ASSETS_HEALTH_INFO_QUERY,
21+
fetchPolicy: 'no-cache',
22+
variables: {
23+
assetKeys,
24+
},
25+
});
26+
27+
const {data} = healthResponse;
28+
29+
return Object.fromEntries(
30+
data.assetNodes.map((node) => [tokenForAssetKey(node.assetKey), node]),
31+
);
32+
},
33+
);
34+
}
35+
export const AssetHealthData = init();
36+
37+
export function useAssetHealthData(assetKey: AssetKeyInput, thread: LiveDataThreadID = 'default') {
38+
const result = AssetHealthData.useLiveDataSingle(tokenForAssetKey(assetKey), thread);
39+
useBlockTraceUntilTrue('useAssetHealthData', !!result.liveData);
40+
return result;
41+
}
42+
43+
export function useAssetsHealthData(
44+
assetKeys: AssetKeyInput[],
45+
thread: LiveDataThreadID = 'AssetHealth', // Use AssetHealth to get 250 batch size
46+
) {
47+
const keys = React.useMemo(() => assetKeys.map((key) => tokenForAssetKey(key)), [assetKeys]);
48+
const result = AssetHealthData.useLiveData(keys, thread);
49+
AssetBaseData.useLiveData(keys, thread);
50+
useBlockTraceUntilTrue(
51+
'useAssetsHealthData',
52+
!!(Object.keys(result.liveDataByNode).length === assetKeys.length),
53+
);
54+
return result;
55+
}
56+
57+
export const ASSETS_HEALTH_INFO_QUERY = gql`
58+
query AssetHealthQuery($assetKeys: [AssetKeyInput!]!) {
59+
assetNodes(assetKeys: $assetKeys) {
60+
id
61+
...AssetHealthFragment
62+
}
63+
}
64+
65+
fragment AssetHealthFragment on AssetNode {
66+
assetKey {
67+
path
68+
}
69+
70+
assetHealth {
71+
assetHealth
72+
materializationStatus
73+
assetChecksStatus
74+
freshnessStatus
75+
}
76+
}
77+
`;
78+
79+
// For tests
80+
export function __resetForJest() {
81+
Object.assign(AssetHealthData, init());
82+
}

Diff for: js_modules/dagster-ui/packages/ui-core/src/asset-data/AssetLiveDataProvider.tsx

+21-4
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import uniq from 'lodash/uniq';
22
import React, {useCallback, useMemo, useRef} from 'react';
33

44
import {AssetBaseData, __resetForJest as __resetBaseData} from './AssetBaseDataProvider';
5+
import {AssetHealthData} from './AssetHealthDataProvider';
56
import {
67
AssetStaleStatusData,
78
__resetForJest as __resetStaleData,
@@ -90,10 +91,17 @@ export const AssetLiveDataProvider = ({children}: {children: React.ReactNode}) =
9091

9192
const staleKeysObserved = useRef<string[]>([]);
9293
const baseKeysObserved = useRef<string[]>([]);
94+
const healthKeysObserved = useRef<string[]>([]);
9395

9496
React.useEffect(() => {
9597
const onSubscriptionsChanged = () => {
96-
const keys = Array.from(new Set(...staleKeysObserved.current, ...baseKeysObserved.current));
98+
const keys = Array.from(
99+
new Set([
100+
...staleKeysObserved.current,
101+
...baseKeysObserved.current,
102+
...healthKeysObserved.current,
103+
]),
104+
);
97105
setAllObservedKeys(keys.map((key) => ({path: key.split('/')})));
98106
};
99107

@@ -105,18 +113,24 @@ export const AssetLiveDataProvider = ({children}: {children: React.ReactNode}) =
105113
baseKeysObserved.current = keys;
106114
onSubscriptionsChanged();
107115
});
116+
AssetHealthData.manager.setOnSubscriptionsChangedCallback((keys) => {
117+
healthKeysObserved.current = keys;
118+
onSubscriptionsChanged();
119+
});
108120
}, []);
109121

110122
const pollRate = React.useContext(LiveDataPollRateContext);
111123

112124
React.useEffect(() => {
113125
AssetStaleStatusData.manager.setPollRate(pollRate);
114126
AssetBaseData.manager.setPollRate(pollRate);
127+
AssetHealthData.manager.setPollRate(pollRate);
115128
}, [pollRate]);
116129

117130
useDidLaunchEvent(() => {
118131
AssetStaleStatusData.manager.invalidateCache();
119132
AssetBaseData.manager.invalidateCache();
133+
AssetHealthData.manager.invalidateCache();
120134
}, SUBSCRIPTION_MAX_POLL_RATE);
121135

122136
React.useEffect(() => {
@@ -148,15 +162,18 @@ export const AssetLiveDataProvider = ({children}: {children: React.ReactNode}) =
148162
) {
149163
AssetBaseData.manager.invalidateCache();
150164
AssetStaleStatusData.manager.invalidateCache();
165+
AssetHealthData.manager.invalidateCache();
151166
}
152167
});
153168
return unobserve;
154169
}, [allObservedKeys]);
155170

156171
return (
157-
<AssetBaseData.LiveDataProvider>
158-
<AssetStaleStatusData.LiveDataProvider>{children}</AssetStaleStatusData.LiveDataProvider>
159-
</AssetBaseData.LiveDataProvider>
172+
<AssetHealthData.LiveDataProvider>
173+
<AssetBaseData.LiveDataProvider>
174+
<AssetStaleStatusData.LiveDataProvider>{children}</AssetStaleStatusData.LiveDataProvider>
175+
</AssetBaseData.LiveDataProvider>
176+
</AssetHealthData.LiveDataProvider>
160177
);
161178
};
162179

Diff for: js_modules/dagster-ui/packages/ui-core/src/asset-data/__tests__/AssetLiveDataProvider.test.tsx

+43
Original file line numberDiff line numberDiff line change
@@ -577,4 +577,47 @@ describe('AssetLiveDataProvider', () => {
577577
expect(resultFn4).toHaveBeenCalled();
578578
});
579579
});
580+
581+
it('should not return live data for keys that we unsubscribe from by changing the keys passed to the hook', async () => {
582+
const assetKeys = [buildAssetKey({path: ['key1']}), buildAssetKey({path: ['key2']})];
583+
const [mockedQuery, mockedFreshnessQuery] = buildMockedAssetGraphLiveQuery(assetKeys);
584+
585+
const resultFn = getMockResultFn(mockedQuery);
586+
587+
const hookResult = jest.fn();
588+
589+
const {rerender} = render(
590+
<Test mocks={[mockedQuery, mockedFreshnessQuery]} hooks={[{keys: assetKeys, hookResult}]} />,
591+
);
592+
593+
// Initially an empty object
594+
expect(resultFn).toHaveBeenCalledTimes(0);
595+
expect(hookResult.mock.calls[0]!.value).toEqual(undefined);
596+
597+
act(() => {
598+
jest.runOnlyPendingTimers();
599+
});
600+
601+
expect(resultFn).toHaveBeenCalled();
602+
await waitFor(() => {
603+
expect(hookResult.mock.calls[1][0]!).toEqual({
604+
['key1']: expect.any(Object),
605+
['key2']: expect.any(Object),
606+
});
607+
});
608+
609+
// Re-render with different asset keys (only asset key1 is in the new set)
610+
611+
const assetKeys2 = [buildAssetKey({path: ['key1']})];
612+
const hookResult2 = jest.fn();
613+
rerender(<Test mocks={[mockedQuery]} hooks={[{keys: assetKeys2, hookResult: hookResult2}]} />);
614+
615+
act(() => {
616+
jest.runOnlyPendingTimers();
617+
});
618+
619+
expect(hookResult2.mock.calls[1][0]).toEqual({
620+
['key1']: expect.any(Object),
621+
});
622+
});
580623
});

Diff for: js_modules/dagster-ui/packages/ui-core/src/asset-data/types/AssetHealthDataProvider.types.ts

+37
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Diff for: js_modules/dagster-ui/packages/ui-core/src/asset-selection/input/useAssetSelectionInput.tsx

+18-16
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import {useMemo} from 'react';
12
import {FeatureFlag} from 'shared/app/FeatureFlags.oss';
23
import {AssetGraphAssetSelectionInput} from 'shared/asset-graph/AssetGraphAssetSelectionInput.oss';
34
import {AssetSelectionInput} from 'shared/asset-selection/input/AssetSelectionInput.oss';
@@ -30,26 +31,27 @@ export const useAssetSelectionInput = <
3031
loading: !!assetsLoading,
3132
});
3233

33-
let filterInput = (
34-
<AssetGraphAssetSelectionInput
35-
items={graphQueryItems}
36-
value={assetSelection}
37-
placeholder="Type an asset subset…"
38-
onChange={setAssetSelection}
39-
popoverPosition="bottom-left"
40-
/>
41-
);
42-
43-
if (featureEnabled(FeatureFlag.flagSelectionSyntax)) {
44-
filterInput = (
45-
<AssetSelectionInput
34+
const filterInput = useMemo(() => {
35+
if (featureEnabled(FeatureFlag.flagSelectionSyntax)) {
36+
return (
37+
<AssetSelectionInput
38+
value={assetSelection}
39+
onChange={setAssetSelection}
40+
assets={graphQueryItems}
41+
onErrorStateChange={onErrorStateChange}
42+
/>
43+
);
44+
}
45+
return (
46+
<AssetGraphAssetSelectionInput
47+
items={graphQueryItems}
4648
value={assetSelection}
49+
placeholder="Type an asset subset…"
4750
onChange={setAssetSelection}
48-
assets={graphQueryItems}
49-
onErrorStateChange={onErrorStateChange}
51+
popoverPosition="bottom-left"
5052
/>
5153
);
52-
}
54+
}, [assetSelection, graphQueryItems, onErrorStateChange, setAssetSelection]);
5355

5456
return {filterInput, loading, filtered: filtered as T[], assetSelection, setAssetSelection};
5557
};

0 commit comments

Comments
 (0)