@truedat/dd 8.13.6 → 8.13.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/src/components/GraphGenerationDialog.js +125 -0
- package/src/components/GraphGenerationNotices.js +67 -0
- package/src/components/GraphLoader.js +12 -17
- package/src/components/GraphRedirector.js +4 -4
- package/src/components/LineageGraph.js +38 -49
- package/src/components/LineageRoutes.js +20 -13
- package/src/components/__tests__/GraphGenerationDialog.spec.js +117 -0
- package/src/components/__tests__/GraphGenerationNotices.spec.js +60 -0
- package/src/components/__tests__/GraphGenerationTranslations.spec.js +74 -0
- package/src/components/__tests__/GraphLoader.spec.js +83 -0
- package/src/components/__tests__/LineageGraphInteraction.spec.js +78 -7
- package/src/components/__tests__/__snapshots__/LineageRoutes.spec.js.snap +12 -1
- package/src/components/__tests__/fixtures/graphGenerationMessages.json +28 -0
- package/src/messages/en.js +0 -29
- package/src/messages/es.js +0 -30
- package/src/reducers/__tests__/graphGenerationState.spec.js +113 -0
- package/src/reducers/graphGenerationState.js +113 -0
- package/src/reducers/graphLoading.js +2 -0
- package/src/reducers/index.js +2 -0
- package/src/routines.js +3 -0
- package/src/sagas/__tests__/createGraph.spec.js +57 -122
- package/src/sagas/__tests__/fetchGraph.spec.js +19 -0
- package/src/sagas/createGraph.js +13 -65
- package/src/sagas/fetchGraph.js +6 -38
- package/src/sagas/graph/__tests__/backgroundGraphs.spec.js +22 -0
- package/src/sagas/graph/__tests__/generation.spec.js +247 -0
- package/src/sagas/graph/__tests__/polling.spec.js +88 -0
- package/src/sagas/graph/generation.js +99 -0
- package/src/sagas/graph/polling.js +26 -35
- package/src/sagas/index.js +2 -0
- package/src/styles/LineageGraph.less +117 -0
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { apiJson } from "@truedat/core/services/api";
|
|
2
|
+
import { runSaga, stdChannel } from "redux-saga";
|
|
3
|
+
import { graphLoading } from "../../../reducers/graphLoading";
|
|
4
|
+
import { graphGenerationState } from "../../../reducers/graphGenerationState";
|
|
5
|
+
import { generateGraph } from "../generation";
|
|
6
|
+
import {
|
|
7
|
+
backgroundGraph,
|
|
8
|
+
createGraph,
|
|
9
|
+
fetchGraph,
|
|
10
|
+
fetchLineageEvents,
|
|
11
|
+
} from "../../../routines";
|
|
12
|
+
|
|
13
|
+
const run = (api) => {
|
|
14
|
+
const actions = [];
|
|
15
|
+
let state = { graphGenerationState: undefined, graphLoading: false };
|
|
16
|
+
const dispatch = (action) => {
|
|
17
|
+
actions.push(action);
|
|
18
|
+
state = {
|
|
19
|
+
graphGenerationState: graphGenerationState(
|
|
20
|
+
state.graphGenerationState,
|
|
21
|
+
action,
|
|
22
|
+
),
|
|
23
|
+
graphLoading: graphLoading(state.graphLoading, action),
|
|
24
|
+
};
|
|
25
|
+
};
|
|
26
|
+
const task = runSaga(
|
|
27
|
+
{ dispatch, getState: () => state },
|
|
28
|
+
generateGraph,
|
|
29
|
+
createGraph,
|
|
30
|
+
api,
|
|
31
|
+
["/graphs", {}],
|
|
32
|
+
true,
|
|
33
|
+
);
|
|
34
|
+
return { task, actions, dispatch, getState: () => state };
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
test("progress is loading, not failure", () => {
|
|
38
|
+
const progress = { data: { graph_hash: "a", retry: 2 } };
|
|
39
|
+
expect(graphLoading(true, fetchGraph.request(progress))).toEqual(progress);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("close before the response keeps the graph in background", async () => {
|
|
43
|
+
let resolve;
|
|
44
|
+
const api = () =>
|
|
45
|
+
new Promise((done) => {
|
|
46
|
+
resolve = done;
|
|
47
|
+
});
|
|
48
|
+
const job = run(api);
|
|
49
|
+
job.dispatch(backgroundGraph());
|
|
50
|
+
resolve({ status: 201, data: { id: 42 } });
|
|
51
|
+
await job.task.toPromise();
|
|
52
|
+
expect(
|
|
53
|
+
job.actions.some((action) => action.type === createGraph.SUCCESS),
|
|
54
|
+
).toBe(false);
|
|
55
|
+
expect(job.getState().graphLoading).toBe(false);
|
|
56
|
+
job.dispatch(
|
|
57
|
+
fetchLineageEvents.success({
|
|
58
|
+
data: [{ graph_id: 42, status: "COMPLETED" }],
|
|
59
|
+
}),
|
|
60
|
+
);
|
|
61
|
+
expect(
|
|
62
|
+
Object.values(job.getState().graphGenerationState.jobs)[0].status,
|
|
63
|
+
).toBe("completed");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("a foreground success publishes the graph", async () => {
|
|
67
|
+
const job = run(async () => ({ status: 201, data: { id: 42 } }));
|
|
68
|
+
await job.task.toPromise();
|
|
69
|
+
expect(
|
|
70
|
+
job.actions.find((action) => action.type === createGraph.SUCCESS).payload
|
|
71
|
+
.data,
|
|
72
|
+
).toEqual({ id: 42, isRedirected: true });
|
|
73
|
+
expect(job.getState().graphLoading).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("network failure is explicit and ends loading", async () => {
|
|
77
|
+
const job = run(async () => {
|
|
78
|
+
throw new Error("network");
|
|
79
|
+
});
|
|
80
|
+
await job.task.toPromise();
|
|
81
|
+
expect(
|
|
82
|
+
Object.values(job.getState().graphGenerationState.jobs)[0].status,
|
|
83
|
+
).toBe("failed");
|
|
84
|
+
expect(
|
|
85
|
+
job.actions.filter((action) => action.type === createGraph.FAILURE),
|
|
86
|
+
).toHaveLength(1);
|
|
87
|
+
expect(job.getState().graphLoading).toBe(false);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("normalizes the GET graph envelope before publishing", async () => {
|
|
91
|
+
const job = run(async () => ({
|
|
92
|
+
status: 200,
|
|
93
|
+
data: { data: { id: 42, resources: [] } },
|
|
94
|
+
}));
|
|
95
|
+
await job.task.toPromise();
|
|
96
|
+
expect(
|
|
97
|
+
job.actions.find((action) => action.type === createGraph.SUCCESS).payload
|
|
98
|
+
.data.id,
|
|
99
|
+
).toBe(42);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("a late response cannot replace or finish the newer foreground request", async () => {
|
|
103
|
+
const actions = [];
|
|
104
|
+
let state = { graphGenerationState: undefined, graphLoading: false };
|
|
105
|
+
const dispatch = (action) => {
|
|
106
|
+
actions.push(action);
|
|
107
|
+
state = {
|
|
108
|
+
graphGenerationState: graphGenerationState(
|
|
109
|
+
state.graphGenerationState,
|
|
110
|
+
action,
|
|
111
|
+
),
|
|
112
|
+
graphLoading: graphLoading(state.graphLoading, action),
|
|
113
|
+
};
|
|
114
|
+
};
|
|
115
|
+
let firstDone;
|
|
116
|
+
let secondDone;
|
|
117
|
+
const environment = { dispatch, getState: () => state };
|
|
118
|
+
const first = runSaga(
|
|
119
|
+
environment,
|
|
120
|
+
generateGraph,
|
|
121
|
+
createGraph,
|
|
122
|
+
() =>
|
|
123
|
+
new Promise((resolve) => {
|
|
124
|
+
firstDone = resolve;
|
|
125
|
+
}),
|
|
126
|
+
["/graphs", {}],
|
|
127
|
+
);
|
|
128
|
+
const second = runSaga(
|
|
129
|
+
environment,
|
|
130
|
+
generateGraph,
|
|
131
|
+
createGraph,
|
|
132
|
+
() =>
|
|
133
|
+
new Promise((resolve) => {
|
|
134
|
+
secondDone = resolve;
|
|
135
|
+
}),
|
|
136
|
+
["/graphs", {}],
|
|
137
|
+
);
|
|
138
|
+
firstDone({ status: 201, data: { id: 1 } });
|
|
139
|
+
await first.toPromise();
|
|
140
|
+
expect(state.graphLoading).toBeTruthy();
|
|
141
|
+
expect(actions.some((action) => action.type === createGraph.SUCCESS)).toBe(
|
|
142
|
+
false,
|
|
143
|
+
);
|
|
144
|
+
secondDone({ status: 201, data: { id: 2 } });
|
|
145
|
+
await second.toPromise();
|
|
146
|
+
expect(
|
|
147
|
+
actions
|
|
148
|
+
.filter((action) => action.type === createGraph.SUCCESS)
|
|
149
|
+
.map((action) => action.payload.data.id),
|
|
150
|
+
).toEqual([2]);
|
|
151
|
+
expect(state.graphLoading).toBe(false);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test.each(["COMPLETED", "FAILED"])(
|
|
155
|
+
"stops polling on a %s event without publishing or failing the active graph",
|
|
156
|
+
async (status) => {
|
|
157
|
+
jest.useFakeTimers();
|
|
158
|
+
const channel = stdChannel();
|
|
159
|
+
const actions = [];
|
|
160
|
+
let state = { graphGenerationState: undefined, graphLoading: false };
|
|
161
|
+
const dispatch = (action) => {
|
|
162
|
+
actions.push(action);
|
|
163
|
+
state = {
|
|
164
|
+
graphGenerationState: graphGenerationState(
|
|
165
|
+
state.graphGenerationState,
|
|
166
|
+
action,
|
|
167
|
+
),
|
|
168
|
+
graphLoading: graphLoading(state.graphLoading, action),
|
|
169
|
+
};
|
|
170
|
+
channel.put(action);
|
|
171
|
+
};
|
|
172
|
+
const api = jest.fn(async () => ({
|
|
173
|
+
status: 202,
|
|
174
|
+
data: { graph_hash: "a" },
|
|
175
|
+
}));
|
|
176
|
+
const task = runSaga(
|
|
177
|
+
{ channel, dispatch, getState: () => state },
|
|
178
|
+
generateGraph,
|
|
179
|
+
createGraph,
|
|
180
|
+
api,
|
|
181
|
+
[],
|
|
182
|
+
);
|
|
183
|
+
try {
|
|
184
|
+
await Promise.resolve();
|
|
185
|
+
await Promise.resolve();
|
|
186
|
+
dispatch(backgroundGraph());
|
|
187
|
+
dispatch(
|
|
188
|
+
fetchLineageEvents.success({
|
|
189
|
+
data: [{ graph_hash: "a", graph_id: 5, status }],
|
|
190
|
+
}),
|
|
191
|
+
);
|
|
192
|
+
await Promise.resolve();
|
|
193
|
+
await Promise.resolve();
|
|
194
|
+
expect(task.isRunning()).toBe(false);
|
|
195
|
+
expect(jest.getTimerCount()).toBe(0);
|
|
196
|
+
expect(
|
|
197
|
+
actions.some(({ type }) =>
|
|
198
|
+
[createGraph.SUCCESS, createGraph.FAILURE].includes(type),
|
|
199
|
+
),
|
|
200
|
+
).toBe(false);
|
|
201
|
+
expect(Object.values(state.graphGenerationState.jobs)[0].status).toBe(
|
|
202
|
+
status === "COMPLETED" ? "completed" : "failed",
|
|
203
|
+
);
|
|
204
|
+
} finally {
|
|
205
|
+
task.cancel();
|
|
206
|
+
jest.useRealTimers();
|
|
207
|
+
}
|
|
208
|
+
},
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
jest.mock("@truedat/core/services/api", () => ({
|
|
212
|
+
apiJson: jest.fn(),
|
|
213
|
+
JSON_OPTS: {},
|
|
214
|
+
}));
|
|
215
|
+
|
|
216
|
+
test.each([false, true])(
|
|
217
|
+
"transient polling errors preserve the job (background=%s)",
|
|
218
|
+
async (background) => {
|
|
219
|
+
jest.useFakeTimers();
|
|
220
|
+
apiJson.mockReset();
|
|
221
|
+
apiJson
|
|
222
|
+
.mockRejectedValueOnce({ response: { status: 503 } })
|
|
223
|
+
.mockResolvedValueOnce({ status: 200, data: { id: 42 } });
|
|
224
|
+
const job = run(async () => ({ status: 202, data: { graph_hash: "a" } }));
|
|
225
|
+
try {
|
|
226
|
+
if (background) job.dispatch(backgroundGraph());
|
|
227
|
+
await jest.advanceTimersByTimeAsync(1000);
|
|
228
|
+
expect(
|
|
229
|
+
Object.values(job.getState().graphGenerationState.jobs)[0].status,
|
|
230
|
+
).toBe("pending");
|
|
231
|
+
expect(job.actions.some(({ type }) => type === createGraph.FAILURE)).toBe(
|
|
232
|
+
false,
|
|
233
|
+
);
|
|
234
|
+
await jest.runAllTimersAsync();
|
|
235
|
+
await job.task.toPromise();
|
|
236
|
+
expect(
|
|
237
|
+
Object.values(job.getState().graphGenerationState.jobs)[0].status,
|
|
238
|
+
).toBe(background ? "awaitingEvent" : "completed");
|
|
239
|
+
expect(job.actions.some(({ type }) => type === createGraph.SUCCESS)).toBe(
|
|
240
|
+
!background,
|
|
241
|
+
);
|
|
242
|
+
} finally {
|
|
243
|
+
job.task.cancel();
|
|
244
|
+
jest.useRealTimers();
|
|
245
|
+
}
|
|
246
|
+
},
|
|
247
|
+
);
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { runSaga } from "redux-saga";
|
|
2
|
+
import { apiJson } from "@truedat/core/services/api";
|
|
3
|
+
import { polling } from "../polling";
|
|
4
|
+
|
|
5
|
+
jest.mock("@truedat/core/services/api", () => ({
|
|
6
|
+
apiJson: jest.fn(),
|
|
7
|
+
JSON_OPTS: {},
|
|
8
|
+
}));
|
|
9
|
+
const accepted = { status: 202, data: { graph_hash: "a" } };
|
|
10
|
+
const completed = { status: 200, data: { id: 42 } };
|
|
11
|
+
const serverError = { response: { status: 503 } };
|
|
12
|
+
const start = () => {
|
|
13
|
+
const onProgress = jest.fn();
|
|
14
|
+
const onError = jest.fn();
|
|
15
|
+
const task = runSaga({ onError }, polling, accepted, onProgress);
|
|
16
|
+
const result = task.toPromise().catch((error) => error);
|
|
17
|
+
return { task, result, onProgress, onError };
|
|
18
|
+
};
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
jest.useFakeTimers();
|
|
21
|
+
apiJson.mockReset();
|
|
22
|
+
});
|
|
23
|
+
afterEach(() => jest.useRealTimers());
|
|
24
|
+
|
|
25
|
+
test.each([new Error("network"), serverError])(
|
|
26
|
+
"recovers from a transient error: %j",
|
|
27
|
+
async (error) => {
|
|
28
|
+
apiJson.mockRejectedValueOnce(error).mockResolvedValueOnce(completed);
|
|
29
|
+
const job = start();
|
|
30
|
+
await jest.runAllTimersAsync();
|
|
31
|
+
expect(await job.result).toEqual(completed);
|
|
32
|
+
expect(apiJson).toHaveBeenCalledTimes(2);
|
|
33
|
+
expect(job.onError).not.toHaveBeenCalled();
|
|
34
|
+
},
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
test("stops after the initial failure and three retries", async () => {
|
|
38
|
+
apiJson.mockRejectedValue(serverError);
|
|
39
|
+
const job = start();
|
|
40
|
+
await jest.runAllTimersAsync();
|
|
41
|
+
expect(await job.result).toBe(serverError);
|
|
42
|
+
expect(apiJson).toHaveBeenCalledTimes(4);
|
|
43
|
+
expect(jest.getTimerCount()).toBe(0);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("resets consecutive failures after a pending response", async () => {
|
|
47
|
+
apiJson
|
|
48
|
+
.mockRejectedValueOnce(serverError)
|
|
49
|
+
.mockRejectedValueOnce(serverError)
|
|
50
|
+
.mockRejectedValueOnce(serverError)
|
|
51
|
+
.mockResolvedValueOnce(accepted)
|
|
52
|
+
.mockRejectedValueOnce(serverError)
|
|
53
|
+
.mockRejectedValueOnce(serverError)
|
|
54
|
+
.mockRejectedValueOnce(serverError)
|
|
55
|
+
.mockResolvedValueOnce(completed);
|
|
56
|
+
const job = start();
|
|
57
|
+
await jest.runAllTimersAsync();
|
|
58
|
+
expect(await job.result).toEqual(completed);
|
|
59
|
+
expect(apiJson).toHaveBeenCalledTimes(8);
|
|
60
|
+
expect(job.onProgress).toHaveBeenCalledWith({
|
|
61
|
+
...accepted,
|
|
62
|
+
data: { graph_hash: "a", retry: 1 },
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test.each([400, 401, 403, 404, 429])(
|
|
67
|
+
"does not retry HTTP %s",
|
|
68
|
+
async (status) => {
|
|
69
|
+
const error = { response: { status } };
|
|
70
|
+
apiJson.mockRejectedValue(error);
|
|
71
|
+
const job = start();
|
|
72
|
+
await jest.runAllTimersAsync();
|
|
73
|
+
expect(await job.result).toBe(error);
|
|
74
|
+
expect(apiJson).toHaveBeenCalledTimes(1);
|
|
75
|
+
},
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
test("cancels the retry delay without further requests", async () => {
|
|
79
|
+
apiJson.mockRejectedValueOnce(serverError).mockResolvedValueOnce(completed);
|
|
80
|
+
const job = start();
|
|
81
|
+
await jest.advanceTimersByTimeAsync(1000);
|
|
82
|
+
expect(job.task.isRunning()).toBe(true);
|
|
83
|
+
job.task.cancel();
|
|
84
|
+
await job.result;
|
|
85
|
+
await jest.runAllTimersAsync();
|
|
86
|
+
expect(apiJson).toHaveBeenCalledTimes(1);
|
|
87
|
+
expect(jest.getTimerCount()).toBe(0);
|
|
88
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import uniqueId from "lodash/fp/uniqueId";
|
|
2
|
+
import { call, delay, put, race, select, take } from "redux-saga/effects";
|
|
3
|
+
import {
|
|
4
|
+
backgroundGraph,
|
|
5
|
+
clearGraph,
|
|
6
|
+
dismissGraphGeneration,
|
|
7
|
+
fetchGraph,
|
|
8
|
+
fetchLineageEvents,
|
|
9
|
+
graphGeneration,
|
|
10
|
+
} from "../../routines";
|
|
11
|
+
import { fetchLineageEventsSaga } from "../fetchLineageEvents";
|
|
12
|
+
import { polling } from "./polling";
|
|
13
|
+
|
|
14
|
+
export const selectGeneration = (state) => state.graphGenerationState;
|
|
15
|
+
|
|
16
|
+
function* isForeground(requestId) {
|
|
17
|
+
const state = yield select(selectGeneration);
|
|
18
|
+
const job = state?.jobs[requestId];
|
|
19
|
+
return state?.active === requestId && Boolean(job) && !job.background;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function* waitForGenerationEnd(requestId) {
|
|
23
|
+
while (true) {
|
|
24
|
+
const state = yield select(selectGeneration);
|
|
25
|
+
const job = state?.jobs[requestId];
|
|
26
|
+
if (!job || ["completed", "failed"].includes(job.status)) return;
|
|
27
|
+
yield take([fetchLineageEvents.SUCCESS, dismissGraphGeneration.TRIGGER]);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function* generateGraph(routine, api, args, isRedirected = false) {
|
|
32
|
+
const requestId = uniqueId("graph-");
|
|
33
|
+
yield put(graphGeneration.request({ requestId }));
|
|
34
|
+
yield put(routine.request());
|
|
35
|
+
function* progress(response) {
|
|
36
|
+
yield put(graphGeneration({ requestId, hash: response.data.graph_hash }));
|
|
37
|
+
if (yield call(isForeground, requestId))
|
|
38
|
+
yield put(fetchGraph.request(response));
|
|
39
|
+
}
|
|
40
|
+
try {
|
|
41
|
+
const accepted = yield call(api, ...args);
|
|
42
|
+
if (accepted.status === 202) yield call(progress, accepted);
|
|
43
|
+
const { response } =
|
|
44
|
+
accepted.status === 202
|
|
45
|
+
? yield race({
|
|
46
|
+
completed: call(waitForGenerationEnd, requestId),
|
|
47
|
+
response: call(polling, accepted, progress),
|
|
48
|
+
})
|
|
49
|
+
: { response: accepted };
|
|
50
|
+
if (!response) return;
|
|
51
|
+
if (![200, 201].includes(response.status))
|
|
52
|
+
throw new Error("Unexpected graph response");
|
|
53
|
+
// GET /graphs/:id wraps the graph in data; POST and hash polling may return it directly.
|
|
54
|
+
const graph = response.data?.data || response.data;
|
|
55
|
+
if (graph?.id == null) throw new Error("Missing graph id");
|
|
56
|
+
const foreground = yield call(isForeground, requestId);
|
|
57
|
+
if (foreground) {
|
|
58
|
+
yield put(
|
|
59
|
+
routine.success({ ...response, data: { ...graph, isRedirected } }),
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
yield put(
|
|
63
|
+
graphGeneration.success({
|
|
64
|
+
requestId,
|
|
65
|
+
graphId: graph.id,
|
|
66
|
+
status: foreground ? "completed" : "awaitingEvent",
|
|
67
|
+
}),
|
|
68
|
+
);
|
|
69
|
+
yield put(fetchLineageEvents());
|
|
70
|
+
} catch (error) {
|
|
71
|
+
if (yield call(isForeground, requestId))
|
|
72
|
+
yield put(routine.failure(error.response || error.message));
|
|
73
|
+
yield put(graphGeneration.failure({ requestId, status: "failed" }));
|
|
74
|
+
} finally {
|
|
75
|
+
// A late job must not clear the loading state of a newer request.
|
|
76
|
+
if (yield call(isForeground, requestId)) yield put(routine.fulfill());
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function* watchBackgroundGraphs() {
|
|
81
|
+
while (true) {
|
|
82
|
+
yield take([
|
|
83
|
+
backgroundGraph.TRIGGER,
|
|
84
|
+
clearGraph.TRIGGER,
|
|
85
|
+
graphGeneration.REQUEST,
|
|
86
|
+
]);
|
|
87
|
+
while (true) {
|
|
88
|
+
const generation = yield select(selectGeneration);
|
|
89
|
+
const pending = Object.values(generation?.jobs || {}).some(
|
|
90
|
+
(job) =>
|
|
91
|
+
job.notify && ["pending", "awaitingEvent"].includes(job.status),
|
|
92
|
+
);
|
|
93
|
+
if (!pending) break;
|
|
94
|
+
const loading = yield select((state) => state.lineageEventsLoading);
|
|
95
|
+
if (!loading) yield call(fetchLineageEventsSaga);
|
|
96
|
+
yield delay(3000);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -1,14 +1,11 @@
|
|
|
1
1
|
/* eslint-disable fp/no-mutation */
|
|
2
2
|
/* eslint-disable fp/no-let */
|
|
3
3
|
|
|
4
|
-
import { call,
|
|
4
|
+
import { call, delay } from "redux-saga/effects";
|
|
5
5
|
import { apiJson, JSON_OPTS } from "@truedat/core/services/api";
|
|
6
6
|
import { compile } from "path-to-regexp";
|
|
7
|
-
import { fetchGraph } from "../../routines";
|
|
8
7
|
import { API_GRAPH_HASH } from "../../api";
|
|
9
8
|
|
|
10
|
-
export const POLLING_CANCELLED = "Polling cancelled!";
|
|
11
|
-
|
|
12
9
|
function* exponentialBackoff(base, power) {
|
|
13
10
|
while (true) {
|
|
14
11
|
yield Math.pow(base, power);
|
|
@@ -17,15 +14,16 @@ function* exponentialBackoff(base, power) {
|
|
|
17
14
|
}
|
|
18
15
|
}
|
|
19
16
|
|
|
20
|
-
|
|
17
|
+
// Return the result to its owner so background work cannot replace the canvas.
|
|
18
|
+
export function* polling(graphAccepted, onProgress) {
|
|
21
19
|
const backoff = exponentialBackoff(1.2, 0);
|
|
22
20
|
let backoffTime,
|
|
23
21
|
retry = 0,
|
|
24
|
-
|
|
25
|
-
|
|
22
|
+
consecutiveErrors = 0,
|
|
23
|
+
graphResp;
|
|
26
24
|
|
|
27
25
|
do {
|
|
28
|
-
backoffTime = backoff.next().value * 1000;
|
|
26
|
+
backoffTime = Math.min(backoff.next().value * 1000, 10000);
|
|
29
27
|
yield delay(backoffTime);
|
|
30
28
|
|
|
31
29
|
try {
|
|
@@ -36,35 +34,28 @@ export function* polling(routine, graphAccepted, isRedirected) {
|
|
|
36
34
|
}),
|
|
37
35
|
JSON_OPTS,
|
|
38
36
|
);
|
|
37
|
+
|
|
38
|
+
consecutiveErrors = 0;
|
|
39
39
|
} catch (error) {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
40
|
+
const status = error.response?.status;
|
|
41
|
+
if (
|
|
42
|
+
(status != null && (status < 500 || status >= 600)) ||
|
|
43
|
+
++consecutiveErrors > 3
|
|
44
|
+
)
|
|
45
|
+
throw error;
|
|
46
|
+
continue;
|
|
46
47
|
}
|
|
47
48
|
|
|
48
49
|
graphResp?.status == 202 &&
|
|
49
|
-
(yield
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
}
|
|
56
|
-
));
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
} while (!isCancelled && graphResp?.status == 202);
|
|
61
|
-
|
|
62
|
-
graphResp?.status == 200 && !isCancelled
|
|
63
|
-
? yield put(
|
|
64
|
-
routine.success({
|
|
65
|
-
...graphResp,
|
|
66
|
-
data: { ...graphResp.data, isRedirected: isRedirected || false },
|
|
67
|
-
}),
|
|
68
|
-
)
|
|
69
|
-
: yield put(routine.failure({}));
|
|
50
|
+
(yield call(onProgress, {
|
|
51
|
+
...graphResp,
|
|
52
|
+
data: {
|
|
53
|
+
...graphResp.data,
|
|
54
|
+
graph_hash: graphAccepted.data.graph_hash,
|
|
55
|
+
retry: ++retry,
|
|
56
|
+
},
|
|
57
|
+
}));
|
|
58
|
+
} while (consecutiveErrors > 0 || graphResp?.status == 202);
|
|
59
|
+
|
|
60
|
+
return graphResp;
|
|
70
61
|
}
|
package/src/sagas/index.js
CHANGED
|
@@ -52,6 +52,7 @@ import { updateSystemRequestSaga } from "./updateSystem";
|
|
|
52
52
|
import { uploadStructuresDomainsRequestSaga } from "./uploadStructuresDomains";
|
|
53
53
|
import { updateTagRequestSaga } from "./updateTag";
|
|
54
54
|
import { upsertCatalogViewConfigRequestSaga } from "./upsertCatalogViewConfig";
|
|
55
|
+
import { watchBackgroundGraphs } from "./graph/generation";
|
|
55
56
|
|
|
56
57
|
export {
|
|
57
58
|
bulkUpdateStructuresRequestsSaga,
|
|
@@ -165,4 +166,5 @@ export default [
|
|
|
165
166
|
updateSystemRequestSaga(),
|
|
166
167
|
uploadStructuresDomainsRequestSaga(),
|
|
167
168
|
upsertCatalogViewConfigRequestSaga(),
|
|
169
|
+
watchBackgroundGraphs(),
|
|
168
170
|
];
|
|
@@ -4125,6 +4125,123 @@ button.lineage-graph__dock-context-reopen {
|
|
|
4125
4125
|
flex-shrink: 0;
|
|
4126
4126
|
}
|
|
4127
4127
|
|
|
4128
|
+
.ui.dimmer.lineage-generation-dimmer {
|
|
4129
|
+
background-color: rgba(0, 0, 0, 0.24);
|
|
4130
|
+
backdrop-filter: blur(4px);
|
|
4131
|
+
-webkit-backdrop-filter: blur(4px);
|
|
4132
|
+
}
|
|
4133
|
+
.ui.basic.modal.lineage-generation-dialog {
|
|
4134
|
+
width: min(16rem, calc(100% - 2rem));
|
|
4135
|
+
padding: 0;
|
|
4136
|
+
&.lineage-generation-dialog--expanded {
|
|
4137
|
+
width: min(32rem, calc(100% - 2rem));
|
|
4138
|
+
.lineage-graph__loading-card {
|
|
4139
|
+
padding: 2.5rem 1.5rem 1.5rem;
|
|
4140
|
+
gap: 1rem;
|
|
4141
|
+
}
|
|
4142
|
+
}
|
|
4143
|
+
.lineage-graph__loading-card {
|
|
4144
|
+
position: relative;
|
|
4145
|
+
min-width: 0;
|
|
4146
|
+
max-width: none;
|
|
4147
|
+
width: 100%;
|
|
4148
|
+
padding: 1.25rem 1.5rem;
|
|
4149
|
+
gap: 0.5rem;
|
|
4150
|
+
}
|
|
4151
|
+
}
|
|
4152
|
+
.lineage-generation-dialog__close {
|
|
4153
|
+
position: absolute;
|
|
4154
|
+
top: 0.25rem;
|
|
4155
|
+
right: 0.25rem;
|
|
4156
|
+
width: 2.75rem;
|
|
4157
|
+
height: 2.75rem;
|
|
4158
|
+
border: 0;
|
|
4159
|
+
background: transparent;
|
|
4160
|
+
color: #435064;
|
|
4161
|
+
cursor: pointer;
|
|
4162
|
+
font-size: 1.5rem;
|
|
4163
|
+
}
|
|
4164
|
+
.lineage-generation-dialog__actions {
|
|
4165
|
+
display: flex;
|
|
4166
|
+
flex-wrap: wrap;
|
|
4167
|
+
gap: 0.75rem;
|
|
4168
|
+
width: 100%;
|
|
4169
|
+
.ui.button {
|
|
4170
|
+
flex: 1 1 10rem;
|
|
4171
|
+
margin: 0;
|
|
4172
|
+
min-height: 2.75rem;
|
|
4173
|
+
}
|
|
4174
|
+
}
|
|
4175
|
+
.lineage-generation-dialog__close:focus,
|
|
4176
|
+
.lineage-generation-dialog__close:focus-visible {
|
|
4177
|
+
outline: none;
|
|
4178
|
+
box-shadow: none;
|
|
4179
|
+
color: var(--td-graph-accent, #ed5c17);
|
|
4180
|
+
}
|
|
4181
|
+
.lineage-generation-dialog__actions .ui.button:focus-visible,
|
|
4182
|
+
.lineage-generation-notices :is(button, a):focus-visible {
|
|
4183
|
+
outline: 2px solid var(--td-graph-accent, #ed5c17);
|
|
4184
|
+
outline-offset: 3px;
|
|
4185
|
+
}
|
|
4186
|
+
.lineage-generation-notices__item {
|
|
4187
|
+
position: relative;
|
|
4188
|
+
display: flex;
|
|
4189
|
+
align-items: center;
|
|
4190
|
+
flex-wrap: wrap;
|
|
4191
|
+
gap: 0.75rem;
|
|
4192
|
+
padding: 0.75rem 1rem;
|
|
4193
|
+
background: #f8fafc;
|
|
4194
|
+
color: #435064;
|
|
4195
|
+
border-bottom: 1px solid #e2e6ed;
|
|
4196
|
+
span {
|
|
4197
|
+
flex: 1 1 15rem;
|
|
4198
|
+
}
|
|
4199
|
+
button {
|
|
4200
|
+
border: 0;
|
|
4201
|
+
background: transparent;
|
|
4202
|
+
cursor: pointer;
|
|
4203
|
+
min-width: 2.75rem;
|
|
4204
|
+
min-height: 2.75rem;
|
|
4205
|
+
}
|
|
4206
|
+
}
|
|
4207
|
+
.lineage-generation-notices__item--ready::after {
|
|
4208
|
+
content: "";
|
|
4209
|
+
position: absolute;
|
|
4210
|
+
inset: 0;
|
|
4211
|
+
border: 2px solid var(--td-graph-accent, #ed5c17);
|
|
4212
|
+
border-radius: inherit;
|
|
4213
|
+
pointer-events: none;
|
|
4214
|
+
opacity: 0;
|
|
4215
|
+
// Animate only opacity so the highlight does not shift the notice or its actions.
|
|
4216
|
+
animation: lineage-generation-ready 2.4s ease-out;
|
|
4217
|
+
}
|
|
4218
|
+
@keyframes lineage-generation-ready {
|
|
4219
|
+
0%,
|
|
4220
|
+
100% {
|
|
4221
|
+
opacity: 0;
|
|
4222
|
+
}
|
|
4223
|
+
15%,
|
|
4224
|
+
40% {
|
|
4225
|
+
opacity: 1;
|
|
4226
|
+
}
|
|
4227
|
+
}
|
|
4228
|
+
@media (prefers-reduced-motion: reduce) {
|
|
4229
|
+
.lineage-generation-dialog .lineage-graph__loading-spinner,
|
|
4230
|
+
.lineage-generation-notices__item--ready::after {
|
|
4231
|
+
animation: none;
|
|
4232
|
+
}
|
|
4233
|
+
}
|
|
4234
|
+
|
|
4235
|
+
.lineage-generation-notices {
|
|
4236
|
+
position: fixed;
|
|
4237
|
+
right: 1rem;
|
|
4238
|
+
bottom: 1rem;
|
|
4239
|
+
width: min(32rem, calc(100% - 2rem));
|
|
4240
|
+
max-height: 40vh;
|
|
4241
|
+
overflow-y: auto;
|
|
4242
|
+
z-index: 900;
|
|
4243
|
+
}
|
|
4244
|
+
|
|
4128
4245
|
.lineage-graph__stage-surface
|
|
4129
4246
|
.react-flow__node.lineage-node--pinned
|
|
4130
4247
|
> :is(.td-lineage-resource-node, .td-lineage-group-node) {
|