diff --git a/js_modules/dagster-ui/packages/ui-components/src/components/RefreshableCountdown.tsx b/js_modules/dagster-ui/packages/ui-components/src/components/RefreshableCountdown.tsx
index 841c1866961ce..4dc25b7ddb829 100644
--- a/js_modules/dagster-ui/packages/ui-components/src/components/RefreshableCountdown.tsx
+++ b/js_modules/dagster-ui/packages/ui-components/src/components/RefreshableCountdown.tsx
@@ -36,7 +36,7 @@ export const RefreshableCountdown = (props: Props) => {
);
};
-const RefreshButton = styled.button`
+export const RefreshButton = styled.button`
border: none;
cursor: pointer;
padding: 8px;
diff --git a/js_modules/dagster-ui/packages/ui-core/src/asset-data/AssetDataRefreshButton.tsx b/js_modules/dagster-ui/packages/ui-core/src/asset-data/AssetDataRefreshButton.tsx
new file mode 100644
index 0000000000000..13b7628cd54c9
--- /dev/null
+++ b/js_modules/dagster-ui/packages/ui-core/src/asset-data/AssetDataRefreshButton.tsx
@@ -0,0 +1,73 @@
+import {Box, Button, Colors, Icon, RefreshButton, Tooltip} from '@dagster-io/ui-components';
+import dayjs from 'dayjs';
+import relativeTime from 'dayjs/plugin/relativeTime';
+import updateLocale from 'dayjs/plugin/updateLocale';
+import React from 'react';
+
+dayjs.extend(relativeTime);
+dayjs.extend(updateLocale);
+
+dayjs.updateLocale('en', {
+ relativeTime: {
+ future: 'in %s',
+ past: '%s ago',
+ s: '%d seconds',
+ m: 'a minute',
+ mm: '%d minutes',
+ h: 'an hour',
+ hh: '%d hours',
+ d: 'a day',
+ dd: '%d days',
+ M: 'a month',
+ MM: '%d months',
+ y: 'a year',
+ yy: '%d years',
+ },
+});
+
+export const AssetDataRefreshButton = ({
+ isRefreshing,
+ onRefresh,
+ oldestDataTimestamp,
+}: {
+ isRefreshing: boolean;
+ onRefresh: () => void;
+ oldestDataTimestamp: number;
+}) => {
+ return (
+
+
+ Click to refresh now
+
+ )
+ }
+ >
+ }
+ onClick={() => {
+ if (!isRefreshing) {
+ onRefresh();
+ }
+ }}
+ />
+
+ );
+};
+
+const TimeFromNowWithSeconds: React.FC<{timestamp: number}> = ({timestamp}) => {
+ const [text, setText] = React.useState(dayjs(timestamp).fromNow(true));
+ React.useEffect(() => {
+ const interval = setInterval(() => {
+ setText(dayjs(timestamp).fromNow(true));
+ }, 1000);
+ return () => {
+ clearInterval(interval);
+ };
+ }, [timestamp]);
+ return <>{text === '0s' ? 'Refreshing asset data…' : `Data is at most ${text} old`}>;
+};
diff --git a/js_modules/dagster-ui/packages/ui-core/src/asset-data/AssetLiveDataProvider.tsx b/js_modules/dagster-ui/packages/ui-core/src/asset-data/AssetLiveDataProvider.tsx
index 39cc21c9027b8..4d7ca344bbb0d 100644
--- a/js_modules/dagster-ui/packages/ui-core/src/asset-data/AssetLiveDataProvider.tsx
+++ b/js_modules/dagster-ui/packages/ui-core/src/asset-data/AssetLiveDataProvider.tsx
@@ -14,6 +14,8 @@ import {AssetKeyInput} from '../graphql/types';
import {isDocumentVisible, useDocumentVisibility} from '../hooks/useDocumentVisibility';
import {useDidLaunchEvent} from '../runs/RunUtils';
+import {AssetDataRefreshButton} from './AssetDataRefreshButton';
+
const _assetKeyListeners: Record> = {};
let providerListener = (_key: string, _data?: LiveDataForNode) => {};
const _cache: Record = {};
@@ -56,11 +58,13 @@ export function useAssetsLiveData(assetKeys: AssetKeyInput[]) {
return {
liveDataByNode: data,
+
refresh: React.useCallback(() => {
_resetLastFetchedOrRequested(assetKeys);
setNeedsImmediateFetch();
setIsRefreshing(true);
}, [setNeedsImmediateFetch, assetKeys]),
+
refreshing: React.useMemo(() => {
for (const key of assetKeys) {
const stringKey = tokenForAssetKey(key);
@@ -120,6 +124,16 @@ const AssetLiveDataContext = React.createContext<{
onUnsubscribed: () => {},
});
+const AssetLiveDataRefreshContext = React.createContext<{
+ isGloballyRefreshing: boolean;
+ oldestDataTimestamp: number;
+ refresh: () => void;
+}>({
+ isGloballyRefreshing: false,
+ oldestDataTimestamp: Infinity,
+ refresh: () => {},
+});
+
// Map of asset keys to their last fetched time and last requested time
const lastFetchedOrRequested: Record<
string,
@@ -143,6 +157,28 @@ export const AssetLiveDataProvider = ({children}: {children: React.ReactNode}) =
const isDocumentVisible = useDocumentVisibility();
+ const [isGloballyRefreshing, setIsGloballyRefreshing] = React.useState(false);
+ const [oldestDataTimestamp, setOldestDataTimestamp] = React.useState(0);
+
+ const onUpdatingOrUpdated = React.useCallback(() => {
+ const allAssetKeys = Object.keys(_assetKeyListeners).filter(
+ (key) => _assetKeyListeners[key]?.length,
+ );
+ let isRefreshing = allAssetKeys.length ? true : false;
+ let oldestDataTimestamp = Infinity;
+ for (const key of allAssetKeys) {
+ if (lastFetchedOrRequested[key]?.fetched) {
+ isRefreshing = false;
+ }
+ oldestDataTimestamp = Math.min(
+ oldestDataTimestamp,
+ lastFetchedOrRequested[key]?.fetched ?? Infinity,
+ );
+ }
+ setIsGloballyRefreshing(isRefreshing);
+ setOldestDataTimestamp(oldestDataTimestamp === Infinity ? 0 : oldestDataTimestamp);
+ }, []);
+
React.useEffect(() => {
if (!isDocumentVisible) {
return;
@@ -150,19 +186,19 @@ export const AssetLiveDataProvider = ({children}: {children: React.ReactNode}) =
// Check for assets to fetch every 5 seconds to simplify logic
// This means assets will be fetched at most 5 + SUBSCRIPTION_IDLE_POLL_RATE after their first fetch
// but then will be fetched every SUBSCRIPTION_IDLE_POLL_RATE after that
- const interval = setInterval(() => fetchData(client), 5000);
- fetchData(client);
+ const interval = setInterval(() => fetchData(client, onUpdatingOrUpdated), 5000);
+ fetchData(client, onUpdatingOrUpdated);
return () => {
clearInterval(interval);
};
- }, [client, isDocumentVisible]);
+ }, [client, isDocumentVisible, onUpdatingOrUpdated]);
React.useEffect(() => {
if (!needsImmediateFetch) {
return;
}
const timeout = setTimeout(() => {
- fetchData(client);
+ fetchData(client, onUpdatingOrUpdated);
setNeedsImmediateFetch(false);
// Wait BATCHING_INTERVAL before doing fetch in case the component is unmounted quickly (eg. in the case of scrolling/filtering quickly)
}, BATCHING_INTERVAL);
@@ -240,7 +276,19 @@ export const AssetLiveDataProvider = ({children}: {children: React.ReactNode}) =
[],
)}
>
- {children}
+ {
+ setIsGloballyRefreshing(true);
+ _resetLastFetchedOrRequested();
+ setNeedsImmediateFetch(true);
+ }, [setNeedsImmediateFetch]),
+ }}
+ >
+ {children}
+
);
};
@@ -250,6 +298,7 @@ async function _batchedQueryAssets(
assetKeys: AssetKeyInput[],
client: ApolloClient,
setData: (data: Record) => void,
+ onUpdatingOrUpdated: () => void,
) {
// Bail if the document isn't visible
if (!assetKeys.length || isFetching) {
@@ -263,6 +312,7 @@ async function _batchedQueryAssets(
requested: requestTime,
};
});
+ onUpdatingOrUpdated();
const data = await _queryAssetKeys(client, assetKeys);
const fetchedTime = Date.now();
assetKeys.forEach((key) => {
@@ -271,10 +321,11 @@ async function _batchedQueryAssets(
};
});
setData(data);
+ onUpdatingOrUpdated();
isFetching = false;
const nextAssets = _determineAssetsToFetch();
if (nextAssets.length) {
- _batchedQueryAssets(nextAssets, client, setData);
+ _batchedQueryAssets(nextAssets, client, setData, onUpdatingOrUpdated);
}
}
@@ -333,19 +384,24 @@ function _determineAssetsToFetch() {
return assetsWithoutData.concat(assetsToFetch).slice(0, BATCH_SIZE);
}
-function fetchData(client: ApolloClient) {
- _batchedQueryAssets(_determineAssetsToFetch(), client, (data) => {
- Object.entries(data).forEach(([key, assetData]) => {
- const listeners = _assetKeyListeners[key];
- providerListener(key, assetData);
- if (!listeners) {
- return;
- }
- listeners.forEach((listener) => {
- listener(key, assetData);
+function fetchData(client: ApolloClient, onUpdatingOrUpdated: () => void) {
+ _batchedQueryAssets(
+ _determineAssetsToFetch(),
+ client,
+ (data) => {
+ Object.entries(data).forEach(([key, assetData]) => {
+ const listeners = _assetKeyListeners[key];
+ providerListener(key, assetData);
+ if (!listeners) {
+ return;
+ }
+ listeners.forEach((listener) => {
+ listener(key, assetData);
+ });
});
- });
- });
+ },
+ onUpdatingOrUpdated,
+ );
}
function getAllAssetKeysWithListeners(): AssetKeyInput[] {
@@ -370,3 +426,16 @@ export function _setCacheEntryForTest(assetKey: AssetKeyInput, data?: LiveDataFo
});
}
}
+
+export function AssetLiveDataRefresh() {
+ const {isGloballyRefreshing, oldestDataTimestamp, refresh} = React.useContext(
+ AssetLiveDataRefreshContext,
+ );
+ return (
+
+ );
+}
diff --git a/js_modules/dagster-ui/packages/ui-core/src/asset-graph/AssetGraphExplorer.tsx b/js_modules/dagster-ui/packages/ui-core/src/asset-graph/AssetGraphExplorer.tsx
index 523952f0985ab..8f474238079d0 100644
--- a/js_modules/dagster-ui/packages/ui-core/src/asset-graph/AssetGraphExplorer.tsx
+++ b/js_modules/dagster-ui/packages/ui-core/src/asset-graph/AssetGraphExplorer.tsx
@@ -17,6 +17,7 @@ import {useHistory} from 'react-router-dom';
import styled from 'styled-components';
import {useFeatureFlags} from '../app/Flags';
+import {AssetLiveDataRefresh} from '../asset-data/AssetLiveDataProvider';
import {LaunchAssetExecutionButton} from '../assets/LaunchAssetExecutionButton';
import {LaunchAssetObservationButton} from '../assets/LaunchAssetObservationButton';
import {AssetKey} from '../assets/types';
@@ -428,6 +429,7 @@ const AssetGraphExplorerWithData: React.FC = ({
+
{
e.stopPropagation();
- console.log('toggling open');
toggleOpen();
}}
style={{cursor: 'pointer'}}