@malloy-publisher/sdk 0.0.228 → 0.0.229
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/dist/{ServerProvider-BnQo3hhW.es.js → ServerProvider-BdQK_q3C.es.js} +780 -715
- package/dist/ServerProvider-n4SO1thp.cjs.js +1 -0
- package/dist/client/api.d.ts +79 -1
- package/dist/client/index.cjs.js +1 -1
- package/dist/client/index.es.js +1 -1
- package/dist/components/Materializations/CreateEnvMaterializationDialog.d.ts +14 -0
- package/dist/components/Materializations/EnvironmentMaterializations.d.ts +7 -0
- package/dist/components/Materializations/MaterializationDetailDialog.d.ts +2 -1
- package/dist/components/Materializations/MaterializationRunsList.d.ts +5 -1
- package/dist/components/Materializations/ScheduleCard.d.ts +20 -0
- package/dist/components/Materializations/SectionLabel.d.ts +10 -0
- package/dist/components/Materializations/SetScheduleDialog.d.ts +11 -0
- package/dist/components/Materializations/TriggerChip.d.ts +10 -0
- package/dist/components/Materializations/cron.d.ts +23 -0
- package/dist/components/Materializations/index.d.ts +1 -0
- package/dist/components/Materializations/utils.d.ts +9 -0
- package/dist/components/Notebook/Notebook.d.ts +6 -1
- package/dist/components/Notebook/NotebookCell.d.ts +3 -1
- package/dist/components/RenderedResult/ResultContainer.d.ts +3 -1
- package/dist/components/RenderedResult/renderLogs.d.ts +6 -0
- package/dist/components/ResultsDialog.d.ts +3 -1
- package/dist/constants/docLinks.d.ts +6 -0
- package/dist/{core-DYlbkyc-.cjs.js → core-BJUrppec.cjs.js} +1 -1
- package/dist/{core-CEDZMHV1.es.js → core-Coi3caGs.es.js} +1 -1
- package/dist/index-DWcuaQOW.cjs.js +309 -0
- package/dist/{index-DC7gIcJX.es.js → index-mbDUet0T.es.js} +34625 -25301
- package/dist/index.cjs.js +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.es.js +54 -52
- package/dist/utils/queryClient.d.ts +5 -0
- package/package.json +3 -1
- package/src/components/Environment/AddPackageDialog.tsx +2 -1
- package/src/components/Environment/Environment.tsx +8 -0
- package/src/components/Home/Home.tsx +5 -4
- package/src/components/Materializations/CreateEnvMaterializationDialog.tsx +176 -0
- package/src/components/Materializations/EnvironmentMaterializations.tsx +264 -0
- package/src/components/Materializations/ManifestView.tsx +2 -3
- package/src/components/Materializations/MaterializationDetailDialog.tsx +121 -95
- package/src/components/Materializations/MaterializationRunsList.tsx +37 -0
- package/src/components/Materializations/Materializations.tsx +86 -1
- package/src/components/Materializations/ScheduleCard.tsx +228 -0
- package/src/components/Materializations/SectionLabel.tsx +27 -0
- package/src/components/Materializations/SetScheduleDialog.tsx +159 -0
- package/src/components/Materializations/TriggerChip.tsx +41 -0
- package/src/components/Materializations/cron.ts +121 -0
- package/src/components/Materializations/index.ts +1 -0
- package/src/components/Materializations/utils.ts +14 -0
- package/src/components/Model/ModelCell.tsx +4 -0
- package/src/components/Notebook/Notebook.tsx +14 -1
- package/src/components/Notebook/NotebookCell.tsx +103 -2
- package/src/components/Package/Package.tsx +14 -3
- package/src/components/QueryResult/QueryResult.tsx +7 -1
- package/src/components/RenderedResult/RenderedResult.tsx +153 -51
- package/src/components/RenderedResult/ResultContainer.tsx +40 -1
- package/src/components/RenderedResult/renderLogs.spec.ts +55 -0
- package/src/components/RenderedResult/renderLogs.ts +40 -0
- package/src/components/ResultsDialog.tsx +4 -0
- package/src/constants/docLinks.ts +17 -0
- package/src/index.ts +1 -0
- package/src/utils/queryClient.ts +14 -0
- package/dist/ServerProvider-CCKhmHeT.cjs.js +0 -1
- package/dist/index-DO6Y8XP_.cjs.js +0 -307
|
@@ -9,7 +9,11 @@ import {
|
|
|
9
9
|
} from "@mui/material";
|
|
10
10
|
import { useQueryClient } from "@tanstack/react-query";
|
|
11
11
|
import React, { useState } from "react";
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
Materialization,
|
|
14
|
+
MaterializationActionActionEnum,
|
|
15
|
+
PackageScopeEnum,
|
|
16
|
+
} from "../../client";
|
|
13
17
|
import {
|
|
14
18
|
useMutationWithApiError,
|
|
15
19
|
useQueryWithApiError,
|
|
@@ -21,6 +25,7 @@ import { useServer } from "../ServerProvider";
|
|
|
21
25
|
import CreateMaterializationDialog from "./CreateMaterializationDialog";
|
|
22
26
|
import MaterializationDetailDialog from "./MaterializationDetailDialog";
|
|
23
27
|
import MaterializationRunsList from "./MaterializationRunsList";
|
|
28
|
+
import ScheduleCard from "./ScheduleCard";
|
|
24
29
|
import { isActiveStatus } from "./utils";
|
|
25
30
|
|
|
26
31
|
const MATERIALIZATION_POLL_MS = 3000;
|
|
@@ -144,6 +149,60 @@ export default function Materializations({
|
|
|
144
149
|
},
|
|
145
150
|
});
|
|
146
151
|
|
|
152
|
+
const currentPackage = packageQuery.data?.data;
|
|
153
|
+
|
|
154
|
+
// Edit the package's materialization.schedule (persisted to publisher.json).
|
|
155
|
+
// A schedule is legal only on a version-scoped package, so enabling one also
|
|
156
|
+
// sets scope: version; clearing (null) leaves scope untouched — scope is an
|
|
157
|
+
// explicit control (updateScope below), so clearing a schedule no longer
|
|
158
|
+
// strands scope: version with no way back. The running scheduler re-arms from
|
|
159
|
+
// the new cron on its next tick — no reload needed.
|
|
160
|
+
const updateSchedule = useMutationWithApiError({
|
|
161
|
+
mutationFn: (schedule: string | null) =>
|
|
162
|
+
apiClients.packages.updatePackage(environmentName, packageName, {
|
|
163
|
+
name: packageName,
|
|
164
|
+
// updatePackage overwrites description from the body — carry the
|
|
165
|
+
// current value through so a schedule edit doesn't drop it.
|
|
166
|
+
description: currentPackage?.description,
|
|
167
|
+
...(schedule ? { scope: PackageScopeEnum.Version } : {}),
|
|
168
|
+
materialization: { schedule },
|
|
169
|
+
}),
|
|
170
|
+
onSuccess(_data, schedule) {
|
|
171
|
+
setNotificationMessage(
|
|
172
|
+
schedule ? "Schedule updated" : "Schedule cleared",
|
|
173
|
+
);
|
|
174
|
+
queryClient.invalidateQueries({
|
|
175
|
+
queryKey: ["package", environmentName, packageName],
|
|
176
|
+
});
|
|
177
|
+
},
|
|
178
|
+
onError(error) {
|
|
179
|
+
setNotificationMessage(error.message);
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// Set the persist scope explicitly (package | version). Independent of the
|
|
184
|
+
// schedule so the version flip a schedule requires can be undone after the
|
|
185
|
+
// schedule is cleared. The server rejects scope: package while a schedule is
|
|
186
|
+
// still set (publish-gate Rule 2), so the UI only offers this when no
|
|
187
|
+
// schedule is active.
|
|
188
|
+
const updateScope = useMutationWithApiError({
|
|
189
|
+
mutationFn: (scope: PackageScopeEnum) =>
|
|
190
|
+
apiClients.packages.updatePackage(environmentName, packageName, {
|
|
191
|
+
name: packageName,
|
|
192
|
+
description: currentPackage?.description,
|
|
193
|
+
scope,
|
|
194
|
+
}),
|
|
195
|
+
onSuccess() {
|
|
196
|
+
setNotificationMessage("Scope updated");
|
|
197
|
+
queryClient.invalidateQueries({
|
|
198
|
+
queryKey: ["package", environmentName, packageName],
|
|
199
|
+
});
|
|
200
|
+
},
|
|
201
|
+
onError(error) {
|
|
202
|
+
setNotificationMessage(error.message);
|
|
203
|
+
},
|
|
204
|
+
});
|
|
205
|
+
|
|
147
206
|
const materializations = (listQuery.data?.data ?? []) as Materialization[];
|
|
148
207
|
const selected =
|
|
149
208
|
materializations.find((row) => row.id === selectedId) ?? null;
|
|
@@ -213,6 +272,32 @@ export default function Materializations({
|
|
|
213
272
|
</Stack>
|
|
214
273
|
</Box>
|
|
215
274
|
|
|
275
|
+
{packageQuery.isSuccess && (
|
|
276
|
+
<ScheduleCard
|
|
277
|
+
schedule={currentPackage?.materialization?.schedule ?? null}
|
|
278
|
+
scope={
|
|
279
|
+
currentPackage?.scope === PackageScopeEnum.Version
|
|
280
|
+
? "version"
|
|
281
|
+
: "package"
|
|
282
|
+
}
|
|
283
|
+
manifestLocation={currentPackage?.manifestLocation ?? null}
|
|
284
|
+
hasFreshness={Boolean(
|
|
285
|
+
currentPackage?.materialization?.freshness,
|
|
286
|
+
)}
|
|
287
|
+
mutable={mutable}
|
|
288
|
+
isSubmitting={updateSchedule.isPending}
|
|
289
|
+
isScopeMutating={updateScope.isPending}
|
|
290
|
+
onSubmit={(schedule) => updateSchedule.mutateAsync(schedule)}
|
|
291
|
+
onScopeChange={(scope) =>
|
|
292
|
+
updateScope.mutateAsync(
|
|
293
|
+
scope === "version"
|
|
294
|
+
? PackageScopeEnum.Version
|
|
295
|
+
: PackageScopeEnum.Package,
|
|
296
|
+
)
|
|
297
|
+
}
|
|
298
|
+
/>
|
|
299
|
+
)}
|
|
300
|
+
|
|
216
301
|
<Box sx={{ mb: 6 }}>
|
|
217
302
|
<Typography
|
|
218
303
|
variant="h6"
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import ScheduleIcon from "@mui/icons-material/Schedule";
|
|
2
|
+
import {
|
|
3
|
+
Box,
|
|
4
|
+
Card,
|
|
5
|
+
CardContent,
|
|
6
|
+
Chip,
|
|
7
|
+
Stack,
|
|
8
|
+
ToggleButton,
|
|
9
|
+
ToggleButtonGroup,
|
|
10
|
+
Tooltip,
|
|
11
|
+
Typography,
|
|
12
|
+
} from "@mui/material";
|
|
13
|
+
import { MONO_FONT_FAMILY } from "../styles";
|
|
14
|
+
import { describeCron, formatNextRun } from "./cron";
|
|
15
|
+
import SetScheduleDialog from "./SetScheduleDialog";
|
|
16
|
+
|
|
17
|
+
type Scope = "package" | "version";
|
|
18
|
+
|
|
19
|
+
type ScheduleCardProps = {
|
|
20
|
+
/** The package's cron, or null when no schedule is declared. */
|
|
21
|
+
schedule: string | null;
|
|
22
|
+
/** The package's persist scope. */
|
|
23
|
+
scope: Scope;
|
|
24
|
+
/** When set, the package is control-plane-driven (orchestrated). */
|
|
25
|
+
manifestLocation: string | null;
|
|
26
|
+
/** True when the package declares a freshness policy (schedule N/A). */
|
|
27
|
+
hasFreshness: boolean;
|
|
28
|
+
mutable: boolean;
|
|
29
|
+
isSubmitting: boolean;
|
|
30
|
+
/** True while a scope change is in flight. */
|
|
31
|
+
isScopeMutating?: boolean;
|
|
32
|
+
onSubmit: (schedule: string | null) => Promise<unknown>;
|
|
33
|
+
/** Persist a new scope (package | version). */
|
|
34
|
+
onScopeChange?: (scope: Scope) => Promise<unknown>;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export default function ScheduleCard({
|
|
38
|
+
schedule,
|
|
39
|
+
scope,
|
|
40
|
+
manifestLocation,
|
|
41
|
+
hasFreshness,
|
|
42
|
+
mutable,
|
|
43
|
+
isSubmitting,
|
|
44
|
+
isScopeMutating,
|
|
45
|
+
onSubmit,
|
|
46
|
+
onScopeChange,
|
|
47
|
+
}: ScheduleCardProps) {
|
|
48
|
+
const orchestrated = Boolean(manifestLocation);
|
|
49
|
+
const info = schedule ? describeCron(schedule) : null;
|
|
50
|
+
// A schedule requires version scope, so scope is locked while one is set —
|
|
51
|
+
// the server would reject scope: package (publish-gate Rule 2). Clearing the
|
|
52
|
+
// schedule unlocks it, giving an explicit way back to package scope.
|
|
53
|
+
const scopeLocked = Boolean(schedule);
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<Card variant="outlined" sx={{ borderRadius: 2, mb: 6 }}>
|
|
57
|
+
<CardContent>
|
|
58
|
+
<Stack
|
|
59
|
+
direction="row"
|
|
60
|
+
justifyContent="space-between"
|
|
61
|
+
alignItems="flex-start"
|
|
62
|
+
spacing={2}
|
|
63
|
+
>
|
|
64
|
+
<Box sx={{ minWidth: 0 }}>
|
|
65
|
+
<Stack
|
|
66
|
+
direction="row"
|
|
67
|
+
alignItems="center"
|
|
68
|
+
spacing={1}
|
|
69
|
+
sx={{ mb: 1 }}
|
|
70
|
+
>
|
|
71
|
+
<ScheduleIcon fontSize="small" color="action" />
|
|
72
|
+
<Typography
|
|
73
|
+
variant="h6"
|
|
74
|
+
sx={{ fontWeight: 600, letterSpacing: "-0.025em" }}
|
|
75
|
+
>
|
|
76
|
+
Schedule
|
|
77
|
+
</Typography>
|
|
78
|
+
</Stack>
|
|
79
|
+
|
|
80
|
+
{orchestrated ? (
|
|
81
|
+
<Box>
|
|
82
|
+
<Chip
|
|
83
|
+
size="small"
|
|
84
|
+
color="default"
|
|
85
|
+
variant="outlined"
|
|
86
|
+
label="Control-plane managed"
|
|
87
|
+
sx={{ mb: 1 }}
|
|
88
|
+
/>
|
|
89
|
+
<Typography variant="body2" color="text.secondary">
|
|
90
|
+
Refresh is driven by the control plane; the built-in
|
|
91
|
+
scheduler does not fire this package.
|
|
92
|
+
</Typography>
|
|
93
|
+
</Box>
|
|
94
|
+
) : info ? (
|
|
95
|
+
<Box>
|
|
96
|
+
<Stack
|
|
97
|
+
direction="row"
|
|
98
|
+
alignItems="center"
|
|
99
|
+
spacing={1}
|
|
100
|
+
sx={{ mb: 0.5, flexWrap: "wrap" }}
|
|
101
|
+
>
|
|
102
|
+
<Chip
|
|
103
|
+
size="small"
|
|
104
|
+
color="success"
|
|
105
|
+
variant="outlined"
|
|
106
|
+
icon={<ScheduleIcon fontSize="small" />}
|
|
107
|
+
label="Active"
|
|
108
|
+
/>
|
|
109
|
+
<Box
|
|
110
|
+
component="code"
|
|
111
|
+
sx={{
|
|
112
|
+
fontFamily: MONO_FONT_FAMILY,
|
|
113
|
+
fontSize: "0.8125rem",
|
|
114
|
+
px: 0.75,
|
|
115
|
+
py: 0.25,
|
|
116
|
+
borderRadius: 1,
|
|
117
|
+
bgcolor: "action.hover",
|
|
118
|
+
}}
|
|
119
|
+
>
|
|
120
|
+
{schedule}
|
|
121
|
+
</Box>
|
|
122
|
+
</Stack>
|
|
123
|
+
<Typography variant="body2">
|
|
124
|
+
{info.valid
|
|
125
|
+
? info.description
|
|
126
|
+
: "Unrecognized cron expression."}
|
|
127
|
+
</Typography>
|
|
128
|
+
{info.valid && (
|
|
129
|
+
<Typography variant="body2" color="text.secondary">
|
|
130
|
+
Next run: {formatNextRun(info.nextRun)}
|
|
131
|
+
</Typography>
|
|
132
|
+
)}
|
|
133
|
+
</Box>
|
|
134
|
+
) : hasFreshness ? (
|
|
135
|
+
<Typography variant="body2" color="text.secondary">
|
|
136
|
+
This package uses a freshness policy; a cron schedule is
|
|
137
|
+
not applicable.
|
|
138
|
+
</Typography>
|
|
139
|
+
) : (
|
|
140
|
+
<Typography variant="body2" color="text.secondary">
|
|
141
|
+
On-demand only — no schedule set.
|
|
142
|
+
</Typography>
|
|
143
|
+
)}
|
|
144
|
+
|
|
145
|
+
{!orchestrated && (
|
|
146
|
+
<Box sx={{ mt: 2 }}>
|
|
147
|
+
<Typography
|
|
148
|
+
variant="caption"
|
|
149
|
+
color="text.secondary"
|
|
150
|
+
sx={{ display: "block", mb: 0.5 }}
|
|
151
|
+
>
|
|
152
|
+
Scope
|
|
153
|
+
</Typography>
|
|
154
|
+
<Tooltip
|
|
155
|
+
title={
|
|
156
|
+
scopeLocked
|
|
157
|
+
? "A schedule requires version scope. Clear the schedule to change it."
|
|
158
|
+
: ""
|
|
159
|
+
}
|
|
160
|
+
>
|
|
161
|
+
<span>
|
|
162
|
+
<ToggleButtonGroup
|
|
163
|
+
size="small"
|
|
164
|
+
exclusive
|
|
165
|
+
value={scope}
|
|
166
|
+
disabled={
|
|
167
|
+
!mutable ||
|
|
168
|
+
!onScopeChange ||
|
|
169
|
+
scopeLocked ||
|
|
170
|
+
Boolean(isScopeMutating)
|
|
171
|
+
}
|
|
172
|
+
onChange={(_e, next: Scope | null) => {
|
|
173
|
+
if (next && next !== scope) {
|
|
174
|
+
onScopeChange?.(next);
|
|
175
|
+
}
|
|
176
|
+
}}
|
|
177
|
+
>
|
|
178
|
+
<ToggleButton value="package">
|
|
179
|
+
package
|
|
180
|
+
</ToggleButton>
|
|
181
|
+
<ToggleButton value="version">
|
|
182
|
+
version
|
|
183
|
+
</ToggleButton>
|
|
184
|
+
</ToggleButtonGroup>
|
|
185
|
+
</span>
|
|
186
|
+
</Tooltip>
|
|
187
|
+
<Typography
|
|
188
|
+
variant="caption"
|
|
189
|
+
color="text.secondary"
|
|
190
|
+
sx={{ display: "block", mt: 0.5 }}
|
|
191
|
+
>
|
|
192
|
+
{scope === "version"
|
|
193
|
+
? "Materialized artifacts are per version (required for a schedule)."
|
|
194
|
+
: "Materialized artifacts are shared across versions."}
|
|
195
|
+
</Typography>
|
|
196
|
+
</Box>
|
|
197
|
+
)}
|
|
198
|
+
|
|
199
|
+
<Typography
|
|
200
|
+
variant="caption"
|
|
201
|
+
color="text.secondary"
|
|
202
|
+
sx={{ display: "block", mt: 1.5 }}
|
|
203
|
+
>
|
|
204
|
+
Declared in <code>publisher.json</code>.
|
|
205
|
+
{!orchestrated && (
|
|
206
|
+
<>
|
|
207
|
+
{" "}
|
|
208
|
+
In a hosted deployment, a change takes effect on your
|
|
209
|
+
next publish.
|
|
210
|
+
</>
|
|
211
|
+
)}
|
|
212
|
+
</Typography>
|
|
213
|
+
</Box>
|
|
214
|
+
|
|
215
|
+
{mutable && !orchestrated && (
|
|
216
|
+
<SetScheduleDialog
|
|
217
|
+
currentSchedule={schedule}
|
|
218
|
+
isSubmitting={isSubmitting}
|
|
219
|
+
disabled={hasFreshness}
|
|
220
|
+
disabledReason="This package declares a freshness policy; a schedule and freshness are mutually exclusive."
|
|
221
|
+
onSubmit={onSubmit}
|
|
222
|
+
/>
|
|
223
|
+
)}
|
|
224
|
+
</Stack>
|
|
225
|
+
</CardContent>
|
|
226
|
+
</Card>
|
|
227
|
+
);
|
|
228
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Typography } from "@mui/material";
|
|
2
|
+
import { ReactNode } from "react";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Small uppercase section heading for consistent hierarchy across the
|
|
6
|
+
* materialization views (detail dialog, manifest view). One source of truth for
|
|
7
|
+
* the style so the headings can't drift. Renders as an <h6> so it is a real
|
|
8
|
+
* heading (role="heading") — the caption styling is purely visual.
|
|
9
|
+
*/
|
|
10
|
+
export default function SectionLabel({ children }: { children: ReactNode }) {
|
|
11
|
+
return (
|
|
12
|
+
<Typography
|
|
13
|
+
variant="caption"
|
|
14
|
+
component="h6"
|
|
15
|
+
sx={{
|
|
16
|
+
display: "block",
|
|
17
|
+
textTransform: "uppercase",
|
|
18
|
+
letterSpacing: "0.06em",
|
|
19
|
+
fontWeight: 600,
|
|
20
|
+
color: "text.secondary",
|
|
21
|
+
mb: 1,
|
|
22
|
+
}}
|
|
23
|
+
>
|
|
24
|
+
{children}
|
|
25
|
+
</Typography>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import ScheduleIcon from "@mui/icons-material/Schedule";
|
|
2
|
+
import {
|
|
3
|
+
Alert,
|
|
4
|
+
Box,
|
|
5
|
+
Button,
|
|
6
|
+
Dialog,
|
|
7
|
+
DialogActions,
|
|
8
|
+
DialogContent,
|
|
9
|
+
DialogContentText,
|
|
10
|
+
DialogTitle,
|
|
11
|
+
TextField,
|
|
12
|
+
Tooltip,
|
|
13
|
+
Typography,
|
|
14
|
+
} from "@mui/material";
|
|
15
|
+
import { useState } from "react";
|
|
16
|
+
import { MONO_FONT_FAMILY } from "../styles";
|
|
17
|
+
import { describeCron, formatNextRun } from "./cron";
|
|
18
|
+
|
|
19
|
+
type SetScheduleDialogProps = {
|
|
20
|
+
/** The current cron, or null when no schedule is set. */
|
|
21
|
+
currentSchedule: string | null;
|
|
22
|
+
isSubmitting: boolean;
|
|
23
|
+
disabled?: boolean;
|
|
24
|
+
disabledReason?: string;
|
|
25
|
+
/** Persist a new cron, or null to clear the schedule. */
|
|
26
|
+
onSubmit: (schedule: string | null) => Promise<unknown>;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const DEFAULT_CRON = "0 6 * * *";
|
|
30
|
+
|
|
31
|
+
export default function SetScheduleDialog({
|
|
32
|
+
currentSchedule,
|
|
33
|
+
isSubmitting,
|
|
34
|
+
disabled,
|
|
35
|
+
disabledReason,
|
|
36
|
+
onSubmit,
|
|
37
|
+
}: SetScheduleDialogProps) {
|
|
38
|
+
const [open, setOpen] = useState(false);
|
|
39
|
+
const [expr, setExpr] = useState(currentSchedule ?? DEFAULT_CRON);
|
|
40
|
+
|
|
41
|
+
const handleOpen = () => {
|
|
42
|
+
setExpr(currentSchedule ?? DEFAULT_CRON);
|
|
43
|
+
setOpen(true);
|
|
44
|
+
};
|
|
45
|
+
const handleClose = () => setOpen(false);
|
|
46
|
+
|
|
47
|
+
const info = describeCron(expr);
|
|
48
|
+
|
|
49
|
+
const submit = async (schedule: string | null) => {
|
|
50
|
+
try {
|
|
51
|
+
await onSubmit(schedule);
|
|
52
|
+
setOpen(false);
|
|
53
|
+
} catch {
|
|
54
|
+
// The caller surfaces the error via its Snackbar; keep the dialog
|
|
55
|
+
// open so the user can correct the cron and retry.
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const button = (
|
|
60
|
+
<span>
|
|
61
|
+
<Button
|
|
62
|
+
variant="outlined"
|
|
63
|
+
size="small"
|
|
64
|
+
startIcon={<ScheduleIcon />}
|
|
65
|
+
onClick={handleOpen}
|
|
66
|
+
disabled={disabled}
|
|
67
|
+
aria-label={currentSchedule ? "Edit schedule" : "Set schedule"}
|
|
68
|
+
>
|
|
69
|
+
{currentSchedule ? "Edit schedule" : "Set schedule"}
|
|
70
|
+
</Button>
|
|
71
|
+
</span>
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<>
|
|
76
|
+
{disabled && disabledReason ? (
|
|
77
|
+
<Tooltip title={disabledReason}>{button}</Tooltip>
|
|
78
|
+
) : (
|
|
79
|
+
button
|
|
80
|
+
)}
|
|
81
|
+
|
|
82
|
+
<Dialog
|
|
83
|
+
open={open}
|
|
84
|
+
onClose={handleClose}
|
|
85
|
+
maxWidth="xs"
|
|
86
|
+
fullWidth
|
|
87
|
+
aria-labelledby="set-schedule-title"
|
|
88
|
+
>
|
|
89
|
+
<DialogTitle id="set-schedule-title">
|
|
90
|
+
{currentSchedule ? "Edit schedule" : "Set schedule"}
|
|
91
|
+
</DialogTitle>
|
|
92
|
+
<DialogContent>
|
|
93
|
+
<DialogContentText sx={{ mb: 2 }}>
|
|
94
|
+
The publisher rebuilds the materializations in this package on
|
|
95
|
+
this cadence. Enter a 5-field UNIX cron, evaluated in UTC. In
|
|
96
|
+
a hosted deployment, the change takes effect on your next
|
|
97
|
+
publish.
|
|
98
|
+
</DialogContentText>
|
|
99
|
+
<TextField
|
|
100
|
+
autoFocus
|
|
101
|
+
fullWidth
|
|
102
|
+
label="Cron expression"
|
|
103
|
+
value={expr}
|
|
104
|
+
onChange={(event) => setExpr(event.target.value)}
|
|
105
|
+
error={expr.trim() !== "" && !info.valid}
|
|
106
|
+
slotProps={{
|
|
107
|
+
htmlInput: { style: { fontFamily: MONO_FONT_FAMILY } },
|
|
108
|
+
}}
|
|
109
|
+
/>
|
|
110
|
+
<Box sx={{ mt: 1.5, minHeight: 48 }}>
|
|
111
|
+
{info.valid ? (
|
|
112
|
+
<>
|
|
113
|
+
<Typography variant="body2">
|
|
114
|
+
{info.description}
|
|
115
|
+
</Typography>
|
|
116
|
+
<Typography variant="body2" color="text.secondary">
|
|
117
|
+
Next run: {formatNextRun(info.nextRun)}
|
|
118
|
+
</Typography>
|
|
119
|
+
</>
|
|
120
|
+
) : (
|
|
121
|
+
<Typography variant="body2" color="error">
|
|
122
|
+
{info.error}
|
|
123
|
+
</Typography>
|
|
124
|
+
)}
|
|
125
|
+
</Box>
|
|
126
|
+
<Alert severity="info" sx={{ mt: 2 }}>
|
|
127
|
+
A schedule runs only on version-scoped packages, so saving one
|
|
128
|
+
sets <code>scope: version</code> for this package.
|
|
129
|
+
</Alert>
|
|
130
|
+
</DialogContent>
|
|
131
|
+
<DialogActions sx={{ justifyContent: "space-between", px: 3 }}>
|
|
132
|
+
<Box>
|
|
133
|
+
{currentSchedule && (
|
|
134
|
+
<Button
|
|
135
|
+
color="error"
|
|
136
|
+
loading={isSubmitting}
|
|
137
|
+
onClick={() => submit(null)}
|
|
138
|
+
>
|
|
139
|
+
Clear schedule
|
|
140
|
+
</Button>
|
|
141
|
+
)}
|
|
142
|
+
</Box>
|
|
143
|
+
<Box>
|
|
144
|
+
<Button onClick={handleClose}>Cancel</Button>
|
|
145
|
+
<Button
|
|
146
|
+
variant="contained"
|
|
147
|
+
loading={isSubmitting}
|
|
148
|
+
disabled={!info.valid}
|
|
149
|
+
onClick={() => submit(expr.trim())}
|
|
150
|
+
sx={{ ml: 1 }}
|
|
151
|
+
>
|
|
152
|
+
Save
|
|
153
|
+
</Button>
|
|
154
|
+
</Box>
|
|
155
|
+
</DialogActions>
|
|
156
|
+
</Dialog>
|
|
157
|
+
</>
|
|
158
|
+
);
|
|
159
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import ScheduleIcon from "@mui/icons-material/Schedule";
|
|
2
|
+
import TouchAppIcon from "@mui/icons-material/TouchApp";
|
|
3
|
+
import { Chip, Tooltip } from "@mui/material";
|
|
4
|
+
import { MaterializationMetadata, triggerLabel } from "./utils";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Chip showing how a run was initiated — Scheduled (the standalone scheduler
|
|
8
|
+
* fired the package's cron) vs Manual (an API/UI create) — with a tooltip. One
|
|
9
|
+
* source of truth for the color/icon/label so the runs list and the detail
|
|
10
|
+
* dialog can never drift.
|
|
11
|
+
*/
|
|
12
|
+
export default function TriggerChip({
|
|
13
|
+
meta,
|
|
14
|
+
}: {
|
|
15
|
+
meta: MaterializationMetadata;
|
|
16
|
+
}) {
|
|
17
|
+
const scheduled = meta.trigger === "SCHEDULER";
|
|
18
|
+
return (
|
|
19
|
+
<Tooltip
|
|
20
|
+
title={
|
|
21
|
+
scheduled
|
|
22
|
+
? "Fired by the materialization schedule"
|
|
23
|
+
: "Triggered manually"
|
|
24
|
+
}
|
|
25
|
+
>
|
|
26
|
+
<Chip
|
|
27
|
+
size="small"
|
|
28
|
+
variant="outlined"
|
|
29
|
+
color={scheduled ? "info" : "default"}
|
|
30
|
+
icon={
|
|
31
|
+
scheduled ? (
|
|
32
|
+
<ScheduleIcon fontSize="small" />
|
|
33
|
+
) : (
|
|
34
|
+
<TouchAppIcon fontSize="small" />
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
label={triggerLabel(meta)}
|
|
38
|
+
/>
|
|
39
|
+
</Tooltip>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { CronExpressionParser } from "cron-parser";
|
|
2
|
+
import cronstrue from "cronstrue";
|
|
3
|
+
|
|
4
|
+
export interface CronInfo {
|
|
5
|
+
valid: boolean;
|
|
6
|
+
/** Human-readable summary, e.g. "Every minute" (empty when invalid). */
|
|
7
|
+
description: string;
|
|
8
|
+
/** Next fire instant in UTC (null when invalid). */
|
|
9
|
+
nextRun: Date | null;
|
|
10
|
+
/** Reason the expression was rejected (set only when `valid` is false). */
|
|
11
|
+
error?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Valid 3-letter month and day-of-week names in UNIX cron. */
|
|
15
|
+
const UNIX_CRON_NAMES = new Set([
|
|
16
|
+
"JAN",
|
|
17
|
+
"FEB",
|
|
18
|
+
"MAR",
|
|
19
|
+
"APR",
|
|
20
|
+
"MAY",
|
|
21
|
+
"JUN",
|
|
22
|
+
"JUL",
|
|
23
|
+
"AUG",
|
|
24
|
+
"SEP",
|
|
25
|
+
"OCT",
|
|
26
|
+
"NOV",
|
|
27
|
+
"DEC",
|
|
28
|
+
"SUN",
|
|
29
|
+
"MON",
|
|
30
|
+
"TUE",
|
|
31
|
+
"WED",
|
|
32
|
+
"THU",
|
|
33
|
+
"FRI",
|
|
34
|
+
"SAT",
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Reject cron-parser / Quartz extensions absent from plain UNIX cron (`L`, `W`,
|
|
39
|
+
* `#`, `?`). Mirrors the server's `CronEvaluator.isPlainUnixGrammar` so the UI
|
|
40
|
+
* preview flags the same expressions the server (and the control plane) reject,
|
|
41
|
+
* rather than describing a cron that would silently never arm after publish.
|
|
42
|
+
*/
|
|
43
|
+
function isPlainUnixGrammar(expr: string): boolean {
|
|
44
|
+
for (const field of expr.trim().split(/\s+/)) {
|
|
45
|
+
if (/[#?]/.test(field)) return false;
|
|
46
|
+
for (const token of field.split(/[,\-/]/)) {
|
|
47
|
+
if (token === "" || token === "*") continue;
|
|
48
|
+
if (
|
|
49
|
+
/[A-Za-z]/.test(token) &&
|
|
50
|
+
!UNIX_CRON_NAMES.has(token.toUpperCase())
|
|
51
|
+
) {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Describe a 5-field UNIX cron for display: a human-readable summary (cronstrue)
|
|
61
|
+
* and the next fire instant (cron-parser, in UTC — matching the publisher's
|
|
62
|
+
* server-side scheduler so the UI shows the same instants it will fire at).
|
|
63
|
+
*
|
|
64
|
+
* The expression is required to be exactly 5 fields and plain UNIX grammar,
|
|
65
|
+
* mirroring the server's `CronEvaluator`: cron-parser also accepts an optional
|
|
66
|
+
* leading seconds field (6 fields) and extensions (L/W/#/?), but the manifest
|
|
67
|
+
* contract is plain 5-field UNIX cron, so those are reported invalid rather
|
|
68
|
+
* than silently reinterpreted (or accepted here yet rejected after publish).
|
|
69
|
+
*/
|
|
70
|
+
export function describeCron(expr: string): CronInfo {
|
|
71
|
+
const trimmed = (expr ?? "").trim();
|
|
72
|
+
if (trimmed.split(/\s+/).filter(Boolean).length !== 5) {
|
|
73
|
+
return {
|
|
74
|
+
valid: false,
|
|
75
|
+
description: "",
|
|
76
|
+
nextRun: null,
|
|
77
|
+
error: "Expected a 5-field UNIX cron: minute hour day-of-month month day-of-week.",
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
if (!isPlainUnixGrammar(trimmed)) {
|
|
81
|
+
return {
|
|
82
|
+
valid: false,
|
|
83
|
+
description: "",
|
|
84
|
+
nextRun: null,
|
|
85
|
+
error: "Unsupported cron syntax: L, W, #, and ? extensions are not allowed (plain 5-field UNIX cron only).",
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
try {
|
|
89
|
+
const description = cronstrue.toString(trimmed, {
|
|
90
|
+
use24HourTimeFormat: true,
|
|
91
|
+
verbose: false,
|
|
92
|
+
});
|
|
93
|
+
const nextRun = CronExpressionParser.parse(trimmed, { tz: "UTC" })
|
|
94
|
+
.next()
|
|
95
|
+
.toDate();
|
|
96
|
+
return { valid: true, description, nextRun };
|
|
97
|
+
} catch (e) {
|
|
98
|
+
return {
|
|
99
|
+
valid: false,
|
|
100
|
+
description: "",
|
|
101
|
+
nextRun: null,
|
|
102
|
+
error: e instanceof Error ? e.message : "Invalid cron expression.",
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Absolute UTC label for a next-run instant, e.g. "Jul 14, 2026, 06:00 UTC". */
|
|
108
|
+
export function formatNextRun(d: Date | null): string {
|
|
109
|
+
if (!d) return "—";
|
|
110
|
+
return (
|
|
111
|
+
d.toLocaleString(undefined, {
|
|
112
|
+
year: "numeric",
|
|
113
|
+
month: "short",
|
|
114
|
+
day: "numeric",
|
|
115
|
+
hour: "2-digit",
|
|
116
|
+
minute: "2-digit",
|
|
117
|
+
timeZone: "UTC",
|
|
118
|
+
hour12: false,
|
|
119
|
+
}) + " UTC"
|
|
120
|
+
);
|
|
121
|
+
}
|