datajunction-ui 0.0.26-alpha.0 → 0.0.27

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.
Files changed (28) hide show
  1. package/package.json +2 -2
  2. package/src/app/components/Search.jsx +41 -33
  3. package/src/app/components/__tests__/Search.test.jsx +46 -11
  4. package/src/app/index.tsx +1 -1
  5. package/src/app/pages/AddEditNodePage/MetricQueryField.jsx +57 -8
  6. package/src/app/pages/AddEditNodePage/UpstreamNodeField.jsx +17 -5
  7. package/src/app/pages/AddEditNodePage/__tests__/index.test.jsx +97 -1
  8. package/src/app/pages/AddEditNodePage/index.jsx +61 -17
  9. package/src/app/pages/NodePage/WatchNodeButton.jsx +12 -5
  10. package/src/app/pages/QueryPlannerPage/MetricFlowGraph.jsx +93 -15
  11. package/src/app/pages/QueryPlannerPage/PreAggDetailsPanel.jsx +2320 -65
  12. package/src/app/pages/QueryPlannerPage/SelectionPanel.jsx +234 -25
  13. package/src/app/pages/QueryPlannerPage/__tests__/MetricFlowGraph.test.jsx +315 -122
  14. package/src/app/pages/QueryPlannerPage/__tests__/PreAggDetailsPanel.test.jsx +2672 -314
  15. package/src/app/pages/QueryPlannerPage/__tests__/SelectionPanel.test.jsx +567 -0
  16. package/src/app/pages/QueryPlannerPage/__tests__/index.test.jsx +480 -55
  17. package/src/app/pages/QueryPlannerPage/index.jsx +1021 -14
  18. package/src/app/pages/QueryPlannerPage/styles.css +1990 -62
  19. package/src/app/pages/Root/__tests__/index.test.jsx +79 -8
  20. package/src/app/pages/Root/index.tsx +1 -1
  21. package/src/app/pages/SQLBuilderPage/__tests__/index.test.jsx +82 -0
  22. package/src/app/pages/SettingsPage/__tests__/CreateServiceAccountModal.test.jsx +37 -0
  23. package/src/app/pages/SettingsPage/__tests__/ServiceAccountsSection.test.jsx +48 -0
  24. package/src/app/pages/SettingsPage/__tests__/index.test.jsx +169 -1
  25. package/src/app/services/DJService.js +492 -3
  26. package/src/app/services/__tests__/DJService.test.jsx +582 -0
  27. package/src/mocks/mockNodes.jsx +36 -0
  28. package/webpack.config.js +27 -0
@@ -1,4 +1,6 @@
1
- import { MarkerType } from 'reactflow';
1
+ // Note: MarkerType.Arrow is just the string "arrow" - we use the literal
2
+ // to avoid importing reactflow in this service (which would bloat the main bundle)
3
+ const MARKER_TYPE_ARROW = 'arrow';
2
4
 
3
5
  const DJ_URL = process.env.REACT_APP_DJ_URL
4
6
  ? process.env.REACT_APP_DJ_URL
@@ -115,6 +117,121 @@ export const DataJunctionAPI = {
115
117
  ).json();
116
118
  },
117
119
 
120
+ // Lightweight GraphQL query for listing cubes with display names (for preset dropdown)
121
+ listCubesForPreset: async function () {
122
+ const query = `
123
+ query ListCubes {
124
+ findNodes(nodeTypes: [CUBE]) {
125
+ name
126
+ current {
127
+ displayName
128
+ }
129
+ }
130
+ }
131
+ `;
132
+
133
+ try {
134
+ const result = await (
135
+ await fetch(DJ_GQL, {
136
+ method: 'POST',
137
+ headers: {
138
+ 'Content-Type': 'application/json',
139
+ },
140
+ credentials: 'include',
141
+ body: JSON.stringify({ query }),
142
+ })
143
+ ).json();
144
+
145
+ // Transform to simple array: [{name, display_name}]
146
+ const nodes = result?.data?.findNodes || [];
147
+ return nodes.map(node => ({
148
+ name: node.name,
149
+ display_name: node.current?.displayName || null,
150
+ }));
151
+ } catch (err) {
152
+ console.error('Failed to fetch cubes via GraphQL:', err);
153
+ return [];
154
+ }
155
+ },
156
+
157
+ // Lightweight GraphQL query for planner page - only fetches fields needed
158
+ // Much faster than REST /cubes/{name}/ which loads all columns, elements, etc.
159
+ cubeForPlanner: async function (name) {
160
+ const query = `
161
+ query GetCubeForPlanner($name: String!) {
162
+ findNodes(names: [$name]) {
163
+ name
164
+ current {
165
+ displayName
166
+ cubeMetrics {
167
+ name
168
+ }
169
+ cubeDimensions {
170
+ name
171
+ }
172
+ materializations {
173
+ name
174
+ config
175
+ schedule
176
+ strategy
177
+ }
178
+ }
179
+ }
180
+ }
181
+ `;
182
+
183
+ try {
184
+ const result = await (
185
+ await fetch(DJ_GQL, {
186
+ method: 'POST',
187
+ headers: {
188
+ 'Content-Type': 'application/json',
189
+ },
190
+ credentials: 'include',
191
+ body: JSON.stringify({ query, variables: { name } }),
192
+ })
193
+ ).json();
194
+
195
+ const node = result?.data?.findNodes?.[0];
196
+ if (!node) {
197
+ return null;
198
+ }
199
+
200
+ // Transform to match the shape expected by QueryPlannerPage
201
+ const current = node.current || {};
202
+ const cubeMetrics = (current.cubeMetrics || []).map(m => m.name);
203
+ const cubeDimensions = (current.cubeDimensions || []).map(d => d.name);
204
+
205
+ // Extract druid_cube materialization if present
206
+ const druidMat = (current.materializations || []).find(
207
+ m => m.name === 'druid_cube',
208
+ );
209
+ const cubeMaterialization = druidMat
210
+ ? {
211
+ strategy: druidMat.strategy,
212
+ schedule: druidMat.schedule,
213
+ lookbackWindow: druidMat.config?.lookback_window,
214
+ druidDatasource: druidMat.config?.druid_datasource,
215
+ preaggTables: druidMat.config?.preagg_tables || [],
216
+ workflowUrls: druidMat.config?.workflow_urls || [],
217
+ timestampColumn: druidMat.config?.timestamp_column,
218
+ timestampFormat: druidMat.config?.timestamp_format,
219
+ }
220
+ : null;
221
+
222
+ return {
223
+ name: node.name,
224
+ display_name: current.displayName,
225
+ cube_node_metrics: cubeMetrics,
226
+ cube_node_dimensions: cubeDimensions,
227
+ cubeMaterialization, // Included so we don't need a second fetch
228
+ };
229
+ } catch (err) {
230
+ console.error('Failed to fetch cube via GraphQL:', err);
231
+ return null;
232
+ }
233
+ },
234
+
118
235
  whoami: async function () {
119
236
  return await (
120
237
  await fetch(`${DJ_URL}/whoami/`, { credentials: 'include' })
@@ -294,7 +411,7 @@ export const DataJunctionAPI = {
294
411
  description
295
412
  primaryKey
296
413
  query
297
- parents { name }
414
+ parents { name type }
298
415
  metricMetadata {
299
416
  direction
300
417
  unit { name }
@@ -758,6 +875,56 @@ export const DataJunctionAPI = {
758
875
  ).json();
759
876
  },
760
877
 
878
+ // Fetch node columns with partition info via GraphQL
879
+ // Used to check if a node has temporal partitions defined
880
+ getNodeColumnsWithPartitions: async function (nodeName) {
881
+ const query = `
882
+ query GetNodeColumnsWithPartitions($name: String!) {
883
+ findNodes(names: [$name]) {
884
+ name
885
+ current {
886
+ columns {
887
+ name
888
+ type
889
+ partition {
890
+ type_
891
+ format
892
+ granularity
893
+ }
894
+ }
895
+ }
896
+ }
897
+ }
898
+ `;
899
+
900
+ try {
901
+ const results = await (
902
+ await fetch(DJ_GQL, {
903
+ method: 'POST',
904
+ headers: { 'Content-Type': 'application/json' },
905
+ credentials: 'include',
906
+ body: JSON.stringify({ query, variables: { name: nodeName } }),
907
+ })
908
+ ).json();
909
+
910
+ const node = results.data?.findNodes?.[0];
911
+ if (!node) return { columns: [], temporalPartitions: [] };
912
+
913
+ const columns = node.current?.columns || [];
914
+ const temporalPartitions = columns.filter(
915
+ col => col.partition?.type_ === 'TEMPORAL',
916
+ );
917
+
918
+ return {
919
+ columns,
920
+ temporalPartitions,
921
+ };
922
+ } catch (err) {
923
+ console.error('Failed to fetch node columns with partitions:', err);
924
+ return { columns: [], temporalPartitions: [] };
925
+ }
926
+ },
927
+
761
928
  node_lineage: async function (name) {
762
929
  return await (
763
930
  await fetch(`${DJ_URL}/nodes/${name}/lineage/`, {
@@ -1074,7 +1241,7 @@ export const DataJunctionAPI = {
1074
1241
  source: parent.name,
1075
1242
  animated: true,
1076
1243
  markerEnd: {
1077
- type: MarkerType.Arrow,
1244
+ type: MARKER_TYPE_ARROW,
1078
1245
  },
1079
1246
  });
1080
1247
  }
@@ -1479,6 +1646,29 @@ export const DataJunctionAPI = {
1479
1646
  );
1480
1647
  return { status: response.status, json: await response.json() };
1481
1648
  },
1649
+ // New V2 cube materialization endpoint for Druid cubes
1650
+ materializeCubeV2: async function (
1651
+ cubeName,
1652
+ schedule,
1653
+ strategy = 'incremental_time',
1654
+ lookbackWindow = '1 DAY',
1655
+ runBackfill = true,
1656
+ ) {
1657
+ const response = await fetch(`${DJ_URL}/cubes/${cubeName}/materialize`, {
1658
+ method: 'POST',
1659
+ headers: {
1660
+ 'Content-Type': 'application/json',
1661
+ },
1662
+ body: JSON.stringify({
1663
+ schedule: schedule,
1664
+ strategy: strategy,
1665
+ lookback_window: lookbackWindow,
1666
+ run_backfill: runBackfill,
1667
+ }),
1668
+ credentials: 'include',
1669
+ });
1670
+ return { status: response.status, json: await response.json() };
1671
+ },
1482
1672
  runBackfill: async function (nodeName, materializationName, partitionValues) {
1483
1673
  const response = await fetch(
1484
1674
  `${DJ_URL}/nodes/${nodeName}/materializations/${materializationName}/backfill`,
@@ -1646,4 +1836,303 @@ export const DataJunctionAPI = {
1646
1836
  });
1647
1837
  return await response.json();
1648
1838
  },
1839
+
1840
+ // =================================
1841
+ // Pre-aggregation API
1842
+ // =================================
1843
+
1844
+ // List pre-aggregations with optional filters
1845
+ listPreaggs: async function (filters = {}) {
1846
+ const params = new URLSearchParams();
1847
+ if (filters.node_name) params.append('node_name', filters.node_name);
1848
+ if (filters.grain) params.append('grain', filters.grain);
1849
+ if (filters.grain_mode) params.append('grain_mode', filters.grain_mode);
1850
+ if (filters.measures) params.append('measures', filters.measures);
1851
+ if (filters.status) params.append('status', filters.status);
1852
+
1853
+ return await (
1854
+ await fetch(`${DJ_URL}/preaggs/?${params}`, {
1855
+ credentials: 'include',
1856
+ })
1857
+ ).json();
1858
+ },
1859
+
1860
+ // Plan pre-aggregations for given metrics and dimensions
1861
+ planPreaggs: async function (
1862
+ metrics,
1863
+ dimensions,
1864
+ strategy = null,
1865
+ schedule = null,
1866
+ lookbackWindow = null,
1867
+ ) {
1868
+ const body = {
1869
+ metrics,
1870
+ dimensions,
1871
+ };
1872
+ if (strategy) body.strategy = strategy;
1873
+ if (schedule) body.schedule = schedule;
1874
+ if (lookbackWindow) body.lookback_window = lookbackWindow;
1875
+
1876
+ const response = await fetch(`${DJ_URL}/preaggs/plan`, {
1877
+ method: 'POST',
1878
+ credentials: 'include',
1879
+ headers: {
1880
+ 'Content-Type': 'application/json',
1881
+ },
1882
+ body: JSON.stringify(body),
1883
+ });
1884
+ const result = await response.json();
1885
+ // If there's an error, include the status for the caller to check
1886
+ if (!response.ok) {
1887
+ return {
1888
+ ...result,
1889
+ _error: true,
1890
+ _status: response.status,
1891
+ message:
1892
+ result.message || result.detail || 'Failed to plan pre-aggregations',
1893
+ };
1894
+ }
1895
+ return result;
1896
+ },
1897
+
1898
+ // Get a specific pre-aggregation by ID
1899
+ getPreagg: async function (preaggId) {
1900
+ return await (
1901
+ await fetch(`${DJ_URL}/preaggs/${preaggId}`, {
1902
+ credentials: 'include',
1903
+ })
1904
+ ).json();
1905
+ },
1906
+
1907
+ // Trigger materialization for a pre-aggregation
1908
+ materializePreagg: async function (preaggId) {
1909
+ const response = await fetch(`${DJ_URL}/preaggs/${preaggId}/materialize`, {
1910
+ method: 'POST',
1911
+ credentials: 'include',
1912
+ headers: {
1913
+ 'Content-Type': 'application/json',
1914
+ },
1915
+ });
1916
+ const result = await response.json();
1917
+ // If there's an error, include the status for the caller to check
1918
+ if (!response.ok) {
1919
+ return {
1920
+ ...result,
1921
+ _error: true,
1922
+ _status: response.status,
1923
+ message: result.message || result.detail || 'Materialization failed',
1924
+ };
1925
+ }
1926
+ return result;
1927
+ },
1928
+
1929
+ // Update a single pre-aggregation's config
1930
+ updatePreaggConfig: async function (
1931
+ preaggId,
1932
+ strategy = null,
1933
+ schedule = null,
1934
+ lookbackWindow = null,
1935
+ ) {
1936
+ const body = {};
1937
+ if (strategy) body.strategy = strategy;
1938
+ if (schedule) body.schedule = schedule;
1939
+ if (lookbackWindow) body.lookback_window = lookbackWindow;
1940
+
1941
+ const response = await fetch(`${DJ_URL}/preaggs/${preaggId}/config`, {
1942
+ method: 'PATCH',
1943
+ credentials: 'include',
1944
+ headers: {
1945
+ 'Content-Type': 'application/json',
1946
+ },
1947
+ body: JSON.stringify(body),
1948
+ });
1949
+ const result = await response.json();
1950
+ if (!response.ok) {
1951
+ return {
1952
+ ...result,
1953
+ _error: true,
1954
+ _status: response.status,
1955
+ message: result.message || result.detail || 'Failed to update config',
1956
+ };
1957
+ }
1958
+ return result;
1959
+ },
1960
+
1961
+ // Deactivate (pause) a pre-aggregation's workflow
1962
+ deactivatePreaggWorkflow: async function (preaggId) {
1963
+ const response = await fetch(`${DJ_URL}/preaggs/${preaggId}/workflow`, {
1964
+ method: 'DELETE',
1965
+ credentials: 'include',
1966
+ });
1967
+ const result = await response.json();
1968
+ if (!response.ok) {
1969
+ return {
1970
+ ...result,
1971
+ _error: true,
1972
+ _status: response.status,
1973
+ message:
1974
+ result.message || result.detail || 'Failed to deactivate workflow',
1975
+ };
1976
+ }
1977
+ return result;
1978
+ },
1979
+
1980
+ // Run a backfill for a pre-aggregation
1981
+ runPreaggBackfill: async function (preaggId, startDate, endDate = null) {
1982
+ const body = {
1983
+ start_date: startDate,
1984
+ };
1985
+ if (endDate) body.end_date = endDate;
1986
+
1987
+ const response = await fetch(`${DJ_URL}/preaggs/${preaggId}/backfill`, {
1988
+ method: 'POST',
1989
+ credentials: 'include',
1990
+ headers: {
1991
+ 'Content-Type': 'application/json',
1992
+ },
1993
+ body: JSON.stringify(body),
1994
+ });
1995
+ const result = await response.json();
1996
+ if (!response.ok) {
1997
+ return {
1998
+ ...result,
1999
+ _error: true,
2000
+ _status: response.status,
2001
+ message: result.message || result.detail || 'Failed to run backfill',
2002
+ };
2003
+ }
2004
+ return result;
2005
+ },
2006
+
2007
+ // Get cube details including materializations
2008
+ getCubeDetails: async function (cubeName) {
2009
+ const response = await fetch(`${DJ_URL}/cubes/${cubeName}`, {
2010
+ credentials: 'include',
2011
+ });
2012
+ if (!response.ok) {
2013
+ return { status: response.status, json: null };
2014
+ }
2015
+ return { status: response.status, json: await response.json() };
2016
+ },
2017
+
2018
+ // Extract Druid cube workflow URLs from cube materializations
2019
+ getCubeWorkflowUrls: async function (cubeName) {
2020
+ console.log('getCubeWorkflowUrls: Fetching for cube', cubeName);
2021
+ const result = await this.getCubeDetails(cubeName);
2022
+ console.log('getCubeWorkflowUrls: Cube details result', result);
2023
+ if (!result.json || !result.json.materializations) {
2024
+ console.log('getCubeWorkflowUrls: No materializations found');
2025
+ return [];
2026
+ }
2027
+ console.log(
2028
+ 'getCubeWorkflowUrls: Materializations',
2029
+ result.json.materializations,
2030
+ );
2031
+ const druidMat = result.json.materializations.find(
2032
+ m => m.name === 'druid_cube',
2033
+ );
2034
+ console.log('getCubeWorkflowUrls: druid_cube materialization', druidMat);
2035
+ if (druidMat && druidMat.config && druidMat.config.workflow_urls) {
2036
+ console.log(
2037
+ 'getCubeWorkflowUrls: Found URLs',
2038
+ druidMat.config.workflow_urls,
2039
+ );
2040
+ return druidMat.config.workflow_urls;
2041
+ }
2042
+ console.log('getCubeWorkflowUrls: No workflow_urls in config');
2043
+ return [];
2044
+ },
2045
+
2046
+ // Get full cube materialization info (for edit/refresh/backfill)
2047
+ getCubeMaterialization: async function (cubeName) {
2048
+ const result = await this.getCubeDetails(cubeName);
2049
+ if (!result.json || !result.json.materializations) {
2050
+ return null;
2051
+ }
2052
+ const druidMat = result.json.materializations.find(
2053
+ m => m.name === 'druid_cube',
2054
+ );
2055
+ if (!druidMat) {
2056
+ return null;
2057
+ }
2058
+ // Return combined info from materialization record and config
2059
+ return {
2060
+ id: druidMat.id,
2061
+ strategy: druidMat.strategy,
2062
+ schedule: druidMat.schedule,
2063
+ lookbackWindow: druidMat.lookback_window,
2064
+ druidDatasource: druidMat.config?.druid_datasource,
2065
+ preaggTables: druidMat.config?.preagg_tables || [],
2066
+ workflowUrls: druidMat.config?.workflow_urls || [],
2067
+ timestampColumn: druidMat.config?.timestamp_column,
2068
+ timestampFormat: druidMat.config?.timestamp_format,
2069
+ };
2070
+ },
2071
+
2072
+ // Refresh cube workflow (re-push to scheduler without backfill)
2073
+ refreshCubeWorkflow: async function (
2074
+ cubeName,
2075
+ schedule,
2076
+ strategy = 'incremental_time',
2077
+ lookbackWindow = '1 DAY',
2078
+ ) {
2079
+ // Re-call materialize with run_backfill=false
2080
+ return this.materializeCubeV2(
2081
+ cubeName,
2082
+ schedule,
2083
+ strategy,
2084
+ lookbackWindow,
2085
+ false, // run_backfill = false for refresh
2086
+ );
2087
+ },
2088
+
2089
+ // Deactivate (pause) a cube's workflow
2090
+ deactivateCubeWorkflow: async function (cubeName) {
2091
+ const response = await fetch(`${DJ_URL}/cubes/${cubeName}/materialize`, {
2092
+ method: 'DELETE',
2093
+ credentials: 'include',
2094
+ });
2095
+ if (!response.ok) {
2096
+ const result = await response.json().catch(() => ({}));
2097
+ return {
2098
+ status: response.status,
2099
+ json: {
2100
+ ...result,
2101
+ message:
2102
+ result.message ||
2103
+ result.detail ||
2104
+ 'Failed to deactivate cube workflow',
2105
+ },
2106
+ };
2107
+ }
2108
+ return { status: response.status, json: await response.json() };
2109
+ },
2110
+
2111
+ // Run cube backfill (trigger ad-hoc backfill with date range)
2112
+ runCubeBackfill: async function (cubeName, startDate, endDate = null) {
2113
+ const body = {
2114
+ start_date: startDate,
2115
+ };
2116
+ if (endDate) body.end_date = endDate;
2117
+
2118
+ const response = await fetch(`${DJ_URL}/cubes/${cubeName}/backfill`, {
2119
+ method: 'POST',
2120
+ credentials: 'include',
2121
+ headers: {
2122
+ 'Content-Type': 'application/json',
2123
+ },
2124
+ body: JSON.stringify(body),
2125
+ });
2126
+ const result = await response.json();
2127
+ if (!response.ok) {
2128
+ return {
2129
+ ...result,
2130
+ _error: true,
2131
+ _status: response.status,
2132
+ message:
2133
+ result.message || result.detail || 'Failed to run cube backfill',
2134
+ };
2135
+ }
2136
+ return result;
2137
+ },
1649
2138
  };