querysub 0.509.0 → 0.511.0
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 +1 -1
- package/spec.txt +1 -4
- package/src/4-querysub/Querysub.ts +2 -0
- package/src/deployManager/components/MachineDetailPage.tsx +1 -1
- package/src/deployManager/components/ServiceDetailPage.tsx +311 -185
- package/src/deployManager/components/ServicesListPage.tsx +5 -4
- package/src/deployManager/components/Tools.tsx +6 -3
- package/src/deployManager/components/deployButtons.tsx +7 -1
- package/src/deployManager/machineApplyMainCode.ts +82 -75
- package/src/deployManager/machineController.ts +2 -2
- package/src/deployManager/machineSchema.ts +108 -106
- package/src/diagnostics/logs/errorNotifications2/ErrorNotificationPage.tsx +1 -1
- package/src/library-components/TypedConfigEditor.tsx +16 -17
- package/src/misc.ts +30 -0
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import { SocketFunction } from "socket-function/SocketFunction";
|
|
2
2
|
import { qreact } from "../../4-dom/qreact";
|
|
3
|
-
import { DEFAULT_OVERLAP_TIME, MACHINE_RESYNC_INTERVAL, MachineServiceController, ServiceConfig } from "../machineSchema";
|
|
3
|
+
import { DEFAULT_OVERLAP_TIME, MACHINE_RESYNC_INTERVAL, MachineServiceController, ServiceConfig, ServiceParameters, getLiveServiceParameters, stripReleaseState } from "../machineSchema";
|
|
4
4
|
import { css } from "typesafecss";
|
|
5
5
|
import { t } from "../../2-proxy/schema2";
|
|
6
6
|
import { Querysub } from "../../4-querysub/Querysub";
|
|
7
7
|
import { currentViewParam, selectedServiceIdParam, selectedMachineIdParam } from "../urlParams";
|
|
8
|
-
import { formatTime, formatVeryNiceDateTime } from "socket-function/src/formatting/format";
|
|
8
|
+
import { formatDateTimeDetailed, formatTime, formatVeryNiceDateTime } from "socket-function/src/formatting/format";
|
|
9
9
|
import { InputPicker } from "../../library-components/InputPicker";
|
|
10
10
|
import { MachinePicker } from "./MachinePicker";
|
|
11
|
-
import { deepCloneJSON, nextId, sort } from "socket-function/src/misc";
|
|
11
|
+
import { deepCloneJSON, nextId, sort, timeInSecond } from "socket-function/src/misc";
|
|
12
12
|
import { InputLabel } from "../../library-components/InputLabel";
|
|
13
13
|
import { Button } from "../../library-components/Button";
|
|
14
14
|
import { isDefined } from "../../misc";
|
|
@@ -26,13 +26,15 @@ import { getLogViewerParams } from "../../diagnostics/logs/IndexedLogs/LogViewer
|
|
|
26
26
|
import { getScreenName } from "../machineApplyMainCode";
|
|
27
27
|
import { getOwnThreadId } from "../../-f-node-discovery/NodeDiscovery";
|
|
28
28
|
import { decodeNodeId } from "sliftutils/misc/https/certs";
|
|
29
|
+
import { showModal } from "../../5-diagnostics/Modal";
|
|
29
30
|
|
|
30
31
|
|
|
31
32
|
|
|
32
33
|
export class ServiceDetailPage extends qreact.Component {
|
|
33
34
|
state = t.state({
|
|
34
|
-
|
|
35
|
-
|
|
35
|
+
// The editor's current value. Purely in the browser — nothing is written anywhere until a deploy is scheduled (or forced). Whether there are unsaved changes is DERIVED by comparing this to the deployed config, never tracked separately.
|
|
36
|
+
editorState: t.type<ServiceConfig | undefined>(undefined),
|
|
37
|
+
isDeploying: t.type(false),
|
|
36
38
|
saveError: t.string,
|
|
37
39
|
expandedErrors: t.lookup({
|
|
38
40
|
expanded: t.type(false)
|
|
@@ -44,21 +46,18 @@ export class ServiceDetailPage extends qreact.Component {
|
|
|
44
46
|
}),
|
|
45
47
|
// Milliseconds; 0 means no scheduled time picked yet (use Deploy Now instead)
|
|
46
48
|
switchTime: t.number(0),
|
|
47
|
-
//
|
|
49
|
+
// Seconds; 0 means use the service's configured overlapTime
|
|
48
50
|
overlapOverride: t.number(0),
|
|
49
51
|
});
|
|
50
52
|
|
|
51
53
|
|
|
52
54
|
|
|
53
|
-
private
|
|
55
|
+
private updateEditorState(updatedConfig: ServiceConfig) {
|
|
56
|
+
// Clone, so the editor state never shares objects with (or mutates) the fetched config
|
|
57
|
+
updatedConfig = deepCloneJSON(updatedConfig);
|
|
54
58
|
// Do not let them update the serviceId, as that would break things
|
|
55
59
|
updatedConfig.serviceId = selectedServiceIdParam.value;
|
|
56
|
-
this.state.
|
|
57
|
-
}
|
|
58
|
-
private updateConfigSynced(updatedConfig: ServiceConfig) {
|
|
59
|
-
Querysub.onCommitFinished(() => {
|
|
60
|
-
void this.updateConfig(updatedConfig);
|
|
61
|
-
});
|
|
60
|
+
this.state.editorState = updatedConfig;
|
|
62
61
|
}
|
|
63
62
|
|
|
64
63
|
private async startWatchingOutput(config: {
|
|
@@ -112,30 +111,23 @@ export class ServiceDetailPage extends qreact.Component {
|
|
|
112
111
|
});
|
|
113
112
|
}
|
|
114
113
|
|
|
115
|
-
//
|
|
116
|
-
private
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
updatedConfig.serviceId = selectedServiceIdParam.value;
|
|
123
|
-
this.state.isSaving = true;
|
|
124
|
-
this.state.saveError = "";
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
Querysub.onCommitFinished(async () => {
|
|
114
|
+
// Deploys the editor's config (with parameters.releaseTime deciding when it goes live). The editor is NEVER reset — it is set to exactly what was deployed, so it keeps its content and simply compares as having no unsaved changes.
|
|
115
|
+
private deployConfig(deployConfig: ServiceConfig) {
|
|
116
|
+
// Do not let them update the serviceId, as that would break things
|
|
117
|
+
deployConfig.serviceId = selectedServiceIdParam.value;
|
|
118
|
+
this.state.saveError = "";
|
|
119
|
+
this.state.isDeploying = true;
|
|
120
|
+
this.runControllerAction(async () => {
|
|
128
121
|
try {
|
|
129
|
-
await MachineServiceController(SocketFunction.browserNodeId()).
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
this.state.
|
|
133
|
-
this.state.
|
|
122
|
+
await MachineServiceController(SocketFunction.browserNodeId()).setServiceConfigs.promise([deployConfig]);
|
|
123
|
+
Querysub.commit(() => {
|
|
124
|
+
this.state.editorState = stripReleaseState(deployConfig);
|
|
125
|
+
this.state.switchTime = 0;
|
|
126
|
+
this.state.overlapOverride = 0;
|
|
134
127
|
});
|
|
135
128
|
} finally {
|
|
136
|
-
Querysub.
|
|
137
|
-
this.state.
|
|
138
|
-
this.state.isSaving = false;
|
|
129
|
+
Querysub.commit(() => {
|
|
130
|
+
this.state.isDeploying = false;
|
|
139
131
|
});
|
|
140
132
|
}
|
|
141
133
|
});
|
|
@@ -149,10 +141,33 @@ export class ServiceDetailPage extends qreact.Component {
|
|
|
149
141
|
Querysub.localCommit(() => {
|
|
150
142
|
this.state.saveError = error instanceof Error ? error.message : String(error);
|
|
151
143
|
});
|
|
144
|
+
} finally {
|
|
145
|
+
// The write => read invalidation keys should also cover this, but explicitly re-request the config as well, so the UI always reflects what the server now has (only AFTER the action fully finishes, so we can't refetch a value from before our write committed)
|
|
146
|
+
MachineServiceController(SocketFunction.browserNodeId()).getServiceConfig.refresh(selectedServiceIdParam.value);
|
|
152
147
|
}
|
|
153
148
|
});
|
|
154
149
|
}
|
|
155
150
|
|
|
151
|
+
private confirmForceDeployNow(deployConfig: ServiceConfig) {
|
|
152
|
+
let modal: { close: () => void } | undefined;
|
|
153
|
+
modal = showModal({
|
|
154
|
+
content: <div className={css.fixed.pos(0, 0).size("100vw", "100vh").hsla(0, 0, 0, 0.5).display("flex").alignItems("center").justifyContent("center")}>
|
|
155
|
+
<div className={css.vbox(12).pad2(20).hsl(0, 0, 98).bord2(0, 0, 40) + " keepModalsOpen"}>
|
|
156
|
+
<div className={css.boldStyle.fontSize(16)}>⚡ Force deploy now?</div>
|
|
157
|
+
<div>This config becomes live immediately. The old instances are shut down right away, with no overlap.</div>
|
|
158
|
+
<div className={css.hbox(10)}>
|
|
159
|
+
<Button hue={0} onClick={() => {
|
|
160
|
+
modal && modal.close();
|
|
161
|
+
deployConfig.parameters.releaseTime = undefined;
|
|
162
|
+
this.deployConfig(deployConfig);
|
|
163
|
+
}}>⚡ Force Deploy Now</Button>
|
|
164
|
+
<Button onClick={() => modal && modal.close()}>Cancel</Button>
|
|
165
|
+
</div>
|
|
166
|
+
</div>
|
|
167
|
+
</div>,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
156
171
|
render() {
|
|
157
172
|
const selectedServiceId = selectedServiceIdParam.value;
|
|
158
173
|
if (!selectedServiceId) return <div>No service selected</div>;
|
|
@@ -162,23 +177,33 @@ export class ServiceDetailPage extends qreact.Component {
|
|
|
162
177
|
let controller = MachineServiceController(SocketFunction.browserNodeId());
|
|
163
178
|
const originalConfig = controller.getServiceConfig(selectedServiceId);
|
|
164
179
|
let serviceConfigType = controller.getServiceConfigType();
|
|
165
|
-
let pendingConfig = controller.getPendingServiceConfig(selectedServiceId);
|
|
166
|
-
let switchover = controller.getServiceSwitchover(selectedServiceId);
|
|
167
180
|
|
|
168
181
|
if (!originalConfig || !serviceConfigType) {
|
|
169
182
|
if (controller.isAnyLoading()) return <div>Loading service...</div>;
|
|
170
183
|
return <div>Service not found</div>;
|
|
171
184
|
}
|
|
172
185
|
|
|
173
|
-
//
|
|
174
|
-
const
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
186
|
+
// Parameters that were deployed but haven't gone live yet
|
|
187
|
+
const scheduledDeploy = (() => {
|
|
188
|
+
let releaseTime = originalConfig.parameters.releaseTime || 0;
|
|
189
|
+
if (!releaseTime || Date.now() >= releaseTime || !originalConfig.oldParameters) return undefined;
|
|
190
|
+
return {
|
|
191
|
+
switchTime: releaseTime,
|
|
192
|
+
overlapTime: originalConfig.parameters.overlapTime || DEFAULT_OVERLAP_TIME,
|
|
193
|
+
};
|
|
194
|
+
})();
|
|
195
|
+
|
|
196
|
+
// The editor shows its own value, else the latest deployed config. Editor values for a DIFFERENT service are ignored (they linger in state when switching services).
|
|
197
|
+
const deployedConfig = stripReleaseState(originalConfig);
|
|
198
|
+
const editorState = this.state.editorState && this.state.editorState.serviceId === selectedServiceId && this.state.editorState || undefined;
|
|
199
|
+
const config = editorState || deployedConfig;
|
|
200
|
+
// Unsaved changes are DERIVED by comparing the editor's value to the deployed config (so editing something back to its deployed value counts as no change)
|
|
201
|
+
const hasUnsavedChanges = !!editorState && configCompareKey(editorState) !== configCompareKey(deployedConfig);
|
|
178
202
|
|
|
179
203
|
// Sort machines by status and heartbeat
|
|
180
204
|
let nextIndexes = new Map<string, number>();
|
|
181
|
-
let
|
|
205
|
+
let machineIds = config.parameters.machineIds || [];
|
|
206
|
+
let machineStatuses = machineIds.map(machineId => {
|
|
182
207
|
let index = nextIndexes.get(machineId) || 0;
|
|
183
208
|
nextIndexes.set(machineId, index + 1);
|
|
184
209
|
let machineInfo = controller.getMachineInfo(machineId);
|
|
@@ -200,7 +225,7 @@ export class ServiceDetailPage extends qreact.Component {
|
|
|
200
225
|
};
|
|
201
226
|
});
|
|
202
227
|
sort(machineStatuses, x => (x.isDisabled ? 3 : x.hasError ? 0 : x.isMachineDead ? 1 : 2) * 1000000 + x.heartbeat);
|
|
203
|
-
let now =
|
|
228
|
+
let now = Querysub.nowDelayed(timeInSecond);
|
|
204
229
|
|
|
205
230
|
let machines = (controller.getMachineList() || []).map(x => controller.getMachineInfo(x)).filter(isDefined);
|
|
206
231
|
sort(machines, x => -x.heartbeat);
|
|
@@ -244,38 +269,64 @@ export class ServiceDetailPage extends qreact.Component {
|
|
|
244
269
|
fillWidth
|
|
245
270
|
value={config.info.title}
|
|
246
271
|
onChangeValue={value => {
|
|
247
|
-
|
|
248
|
-
this.
|
|
272
|
+
config.info.title = value;
|
|
273
|
+
this.updateEditorState(config);
|
|
249
274
|
}}
|
|
250
275
|
/>
|
|
251
276
|
<InputLabel
|
|
252
277
|
label="Key"
|
|
253
278
|
value={config.parameters.key}
|
|
254
279
|
onChangeValue={value => {
|
|
255
|
-
|
|
256
|
-
this.
|
|
280
|
+
config.parameters.key = value;
|
|
281
|
+
this.updateEditorState(config);
|
|
257
282
|
}}
|
|
258
283
|
/>
|
|
284
|
+
<div className={css.fillWidth}>
|
|
285
|
+
<InputLabel
|
|
286
|
+
label="Notes"
|
|
287
|
+
textarea
|
|
288
|
+
fillWidth
|
|
289
|
+
value={config.info.notes}
|
|
290
|
+
onChangeValue={value => {
|
|
291
|
+
config.info.notes = value;
|
|
292
|
+
this.updateEditorState(config);
|
|
293
|
+
}}
|
|
294
|
+
/>
|
|
295
|
+
</div>
|
|
259
296
|
</div>
|
|
260
297
|
|
|
261
298
|
<MachinePicker
|
|
262
299
|
label="Machines"
|
|
263
|
-
picked={
|
|
300
|
+
picked={machineIds}
|
|
264
301
|
addPicked={machineId => {
|
|
265
|
-
|
|
266
|
-
|
|
302
|
+
let updated = deepCloneJSON(config);
|
|
303
|
+
updated.parameters.machineIds = [...(updated.parameters.machineIds || []), machineId];
|
|
304
|
+
this.updateEditorState(updated);
|
|
267
305
|
}}
|
|
268
306
|
removePicked={machineId => {
|
|
269
|
-
let
|
|
307
|
+
let updated = deepCloneJSON(config);
|
|
308
|
+
let updatedMachineIds = updated.parameters.machineIds || [];
|
|
309
|
+
let index = updatedMachineIds.indexOf(machineId);
|
|
270
310
|
if (index !== -1) {
|
|
271
|
-
|
|
311
|
+
updatedMachineIds.splice(index, 1);
|
|
272
312
|
}
|
|
273
|
-
|
|
313
|
+
updated.parameters.machineIds = updatedMachineIds;
|
|
314
|
+
this.updateEditorState(updated);
|
|
315
|
+
}}
|
|
316
|
+
/>
|
|
317
|
+
<InputLabel
|
|
318
|
+
label="Deploy overlap (seconds)"
|
|
319
|
+
integer
|
|
320
|
+
value={Math.round((config.parameters.overlapTime || DEFAULT_OVERLAP_TIME) / timeInSecond)}
|
|
321
|
+
onChangeValue={value => {
|
|
322
|
+
let updated = deepCloneJSON(config);
|
|
323
|
+
updated.parameters.overlapTime = (Number(value) || 0) * timeInSecond;
|
|
324
|
+
this.updateEditorState(updated);
|
|
274
325
|
}}
|
|
275
326
|
/>
|
|
276
327
|
{/* Machine Status */}
|
|
277
|
-
{
|
|
278
|
-
<h3>Deployed Machines ({
|
|
328
|
+
{config.parameters.deploy && <div className={css.vbox(8).fillWidth}>
|
|
329
|
+
<h3>Deployed Machines ({machineIds.length})</h3>
|
|
279
330
|
<div className={css.vbox(4).fillWidth}>
|
|
280
331
|
{machineStatuses.map(({ machineId, machineInfo, serviceInfo, isMachineDead, hasError, isDisabled, index }) => {
|
|
281
332
|
if (!machineInfo) return <div key={machineId}>Loading {machineId}...</div>;
|
|
@@ -324,9 +375,9 @@ export class ServiceDetailPage extends qreact.Component {
|
|
|
324
375
|
<div title={formatVeryNiceDateTime(machineInfo.heartbeat)}>
|
|
325
376
|
Heartbeat: {formatTime(now - machineInfo.heartbeat)}
|
|
326
377
|
</div>
|
|
327
|
-
{
|
|
328
|
-
<div className={css.colorhsl(35, 80, 40).fontWeight("bold")} title={formatVeryNiceDateTime(
|
|
329
|
-
⏻ Shuts down in {formatTime(
|
|
378
|
+
{scheduledDeploy && (
|
|
379
|
+
<div className={css.colorhsl(35, 80, 40).fontWeight("bold")} title={formatVeryNiceDateTime(scheduledDeploy.switchTime)}>
|
|
380
|
+
⏻ Shuts down in {formatTime(scheduledDeploy.switchTime - now)}
|
|
330
381
|
</div>
|
|
331
382
|
)}
|
|
332
383
|
{serviceInfo && (
|
|
@@ -439,58 +490,38 @@ export class ServiceDetailPage extends qreact.Component {
|
|
|
439
490
|
|
|
440
491
|
<div className={css.hbox(12).fillWidth}>
|
|
441
492
|
<button className={css.pad2(12, 8).button.bord2(0, 0, 20)
|
|
442
|
-
+ (
|
|
493
|
+
+ (config.parameters.deploy ? css.hsl(0, 70, 90) : css.hsl(120, 70, 90))}
|
|
443
494
|
onClick={() => {
|
|
444
495
|
if (!selectedServiceId || !config) return;
|
|
445
496
|
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
void this.updateUnsavedChanges(configT);
|
|
497
|
+
let updated = deepCloneJSON(config);
|
|
498
|
+
updated.parameters.deploy = !config.parameters.deploy;
|
|
499
|
+
this.updateEditorState(updated);
|
|
450
500
|
}}>
|
|
451
|
-
{
|
|
501
|
+
{config.parameters.deploy ? "⏸️ Disable" : "🚀 Enable"}
|
|
452
502
|
</button>
|
|
453
503
|
|
|
454
|
-
|
|
504
|
+
{originalConfig.oldParameters && <button
|
|
505
|
+
className={css.pad2(12, 8).button.bord2(0, 0, 20).hsl(35, 70, 85)}
|
|
506
|
+
title="Loads the previous deploy's parameters into the editor, so you can schedule them as a new deploy"
|
|
455
507
|
onClick={() => {
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
508
|
+
const oldParameters = originalConfig.oldParameters;
|
|
509
|
+
if (!oldParameters) return;
|
|
510
|
+
this.updateEditorState({
|
|
511
|
+
...stripReleaseState(originalConfig),
|
|
512
|
+
parameters: { ...oldParameters, releaseTime: undefined },
|
|
513
|
+
});
|
|
460
514
|
}}>
|
|
461
|
-
|
|
462
|
-
</button>
|
|
463
|
-
|
|
464
|
-
{hasUnsavedChanges && <>
|
|
465
|
-
<Button
|
|
466
|
-
flavor="noui"
|
|
467
|
-
hotkeys={["global+ctrl+s"]}
|
|
468
|
-
showHotkeys
|
|
469
|
-
className={
|
|
470
|
-
css.pad2(12, 8).button.bord2(0, 0, 20).hbox(0).hsl(120, 70, 90)
|
|
471
|
-
}
|
|
472
|
-
onClick={() => {
|
|
473
|
-
if (!hasUnsavedChanges || !selectedServiceId || !this.state.unsavedChanges) return;
|
|
474
|
-
this.updateConfigSynced(this.state.unsavedChanges);
|
|
475
|
-
}
|
|
476
|
-
}>
|
|
477
|
-
{this.state.isSaving ? "Saving..." : "Save Pending"}
|
|
478
|
-
</Button>
|
|
479
|
-
<button
|
|
480
|
-
className={css.pad2(12, 8).button.bord2(0, 0, 20).hsl(50, 80, 50)}
|
|
481
|
-
onClick={() => {
|
|
482
|
-
this.state.unsavedChanges = undefined;
|
|
483
|
-
}}>
|
|
484
|
-
Discard Changes
|
|
485
|
-
</button>
|
|
486
|
-
</>}
|
|
515
|
+
↩️ Revert to Previous Deploy
|
|
516
|
+
</button>}
|
|
487
517
|
|
|
488
518
|
{gitInfo?.repoUrl === config.parameters.repoUrl && gitInfo.latestRef !== config.parameters.gitRef &&
|
|
489
519
|
<button
|
|
490
520
|
className={buttonStyle.hsl(120, 70, 90).alignSelf("stretch")}
|
|
491
|
-
onClick={
|
|
492
|
-
|
|
493
|
-
|
|
521
|
+
onClick={() => {
|
|
522
|
+
let updated = deepCloneJSON(config);
|
|
523
|
+
updated.parameters.gitRef = gitInfo.latestRef;
|
|
524
|
+
this.updateEditorState(updated);
|
|
494
525
|
}}
|
|
495
526
|
>
|
|
496
527
|
<b>Update to New</b>
|
|
@@ -505,10 +536,6 @@ export class ServiceDetailPage extends qreact.Component {
|
|
|
505
536
|
</button>
|
|
506
537
|
}
|
|
507
538
|
|
|
508
|
-
{this.state.isSaving && <div className={css.pad2(12, 8).bord2(0, 0, 20) + " rainbow-flash"}>
|
|
509
|
-
Deploying changes
|
|
510
|
-
</div>}
|
|
511
|
-
|
|
512
539
|
<div className={css.marginAuto}></div>
|
|
513
540
|
|
|
514
541
|
<button className={css.pad2(12, 8).button.bord2(0, 0, 20).hsl(110, 70, 90)}
|
|
@@ -526,7 +553,7 @@ export class ServiceDetailPage extends qreact.Component {
|
|
|
526
553
|
await controller.addServiceConfig.promise(newServiceId, clonedConfig);
|
|
527
554
|
Querysub.commit(() => {
|
|
528
555
|
selectedServiceIdParam.value = newServiceId;
|
|
529
|
-
this.state.
|
|
556
|
+
this.state.editorState = undefined;
|
|
530
557
|
});
|
|
531
558
|
} catch (error) {
|
|
532
559
|
Querysub.localCommit(() => {
|
|
@@ -542,7 +569,7 @@ export class ServiceDetailPage extends qreact.Component {
|
|
|
542
569
|
onClick={() => {
|
|
543
570
|
if (!selectedServiceId) return;
|
|
544
571
|
|
|
545
|
-
const confirmed = confirm(`⚠️ WARNING: This will permanently delete the service "${
|
|
572
|
+
const confirmed = confirm(`⚠️ WARNING: This will permanently delete the service "${config.info.title}" and remove it from all machines.\n\nThis action cannot be undone. Are you sure you want to continue?`);
|
|
546
573
|
if (!confirmed) return;
|
|
547
574
|
|
|
548
575
|
Querysub.onCommitFinished(async () => {
|
|
@@ -556,10 +583,6 @@ export class ServiceDetailPage extends qreact.Component {
|
|
|
556
583
|
Querysub.localCommit(() => {
|
|
557
584
|
this.state.saveError = error instanceof Error ? error.message : String(error);
|
|
558
585
|
});
|
|
559
|
-
} finally {
|
|
560
|
-
Querysub.localCommit(() => {
|
|
561
|
-
this.state.isSaving = false;
|
|
562
|
-
});
|
|
563
586
|
}
|
|
564
587
|
});
|
|
565
588
|
}}>
|
|
@@ -567,94 +590,124 @@ export class ServiceDetailPage extends qreact.Component {
|
|
|
567
590
|
</button>
|
|
568
591
|
</div>
|
|
569
592
|
|
|
570
|
-
{
|
|
571
|
-
|
|
593
|
+
{/* The deploy that is scheduled on the SERVER — completely independent from any editor changes below */}
|
|
594
|
+
{scheduledDeploy && <div className={css.vbox(10).pad2(12).fillWidth.bord2(210, 60, 50).hsl(210, 40, 95)}>
|
|
595
|
+
<div className={css.boldStyle.fontSize(15)}>
|
|
596
|
+
🕐 Deploy scheduled for {formatTime(scheduledDeploy.switchTime - now)} ({formatDateTimeDetailed(scheduledDeploy.switchTime)})
|
|
597
|
+
</div>
|
|
598
|
+
<ParametersDiff base={getLiveServiceParameters(originalConfig)} next={originalConfig.parameters} />
|
|
599
|
+
<div className={css.vbox(4)}>
|
|
600
|
+
<div>
|
|
601
|
+
Live instance deployed at {scheduledDeploy.switchTime - scheduledDeploy.overlapTime <= now && `${formatTime(now - (scheduledDeploy.switchTime - scheduledDeploy.overlapTime))} ago` || `in ${formatTime(scheduledDeploy.switchTime - scheduledDeploy.overlapTime - now)}`} ({formatDateTimeDetailed(scheduledDeploy.switchTime - scheduledDeploy.overlapTime)})
|
|
602
|
+
</div>
|
|
603
|
+
<div className={css.hbox(8)}>
|
|
604
|
+
<button
|
|
605
|
+
className={css.pad2(12, 8).button.bord2(0, 0, 20).hsl(0, 70, 90)}
|
|
606
|
+
onClick={() => {
|
|
607
|
+
this.runControllerAction(async () => {
|
|
608
|
+
let cancelled = await controller.cancelScheduledDeploy.promise(selectedServiceId);
|
|
609
|
+
// Keep the cancelled changes in the editor, so they aren't lost (Discard Changes drops them)
|
|
610
|
+
Querysub.commit(() => {
|
|
611
|
+
this.state.editorState = cancelled;
|
|
612
|
+
});
|
|
613
|
+
});
|
|
614
|
+
}}>
|
|
615
|
+
Cancel Scheduled Deploy
|
|
616
|
+
</button>
|
|
617
|
+
</div>
|
|
618
|
+
</div>
|
|
619
|
+
</div>}
|
|
620
|
+
|
|
621
|
+
{/* The EDITOR's changes (browser-only) — deploying them schedules a (new) deploy */}
|
|
622
|
+
{(() => {
|
|
623
|
+
if (!hasUnsavedChanges) return undefined;
|
|
624
|
+
let liveParameters = getLiveServiceParameters(originalConfig);
|
|
625
|
+
// Only parameter changes need a release; info-only edits just save immediately
|
|
626
|
+
let parametersChanged = JSON.stringify({ ...config.parameters, releaseTime: undefined }) !== JSON.stringify({ ...liveParameters, releaseTime: undefined });
|
|
627
|
+
if (!parametersChanged) {
|
|
628
|
+
return <div className={css.hbox(12)}>
|
|
629
|
+
<button
|
|
630
|
+
className={css.pad2(12, 8).button.bord2(0, 0, 20).hsl(120, 70, 85).fontWeight("bold")}
|
|
631
|
+
disabled={this.state.isDeploying}
|
|
632
|
+
onClick={() => {
|
|
633
|
+
this.deployConfig(deepCloneJSON(config));
|
|
634
|
+
}}>
|
|
635
|
+
{this.state.isDeploying && "⏳ Saving..." || "💾 Save"}
|
|
636
|
+
</button>
|
|
637
|
+
<span title={`${configCompareKey(config)} !== ${configCompareKey(deployedConfig)}`}>Only the title / notes changed, so this saves immediately</span>
|
|
638
|
+
</div>;
|
|
639
|
+
}
|
|
640
|
+
let overlapSeconds = this.state.overlapOverride || Math.round((config.parameters.overlapTime || DEFAULT_OVERLAP_TIME) / timeInSecond);
|
|
641
|
+
let overlap = overlapSeconds * timeInSecond;
|
|
572
642
|
let scheduledTime = this.state.switchTime;
|
|
573
|
-
let notEnoughTime = !!scheduledTime && scheduledTime - now < overlap;
|
|
574
|
-
|
|
643
|
+
let notEnoughTime = !!scheduledTime && scheduledTime > now && scheduledTime - now < overlap;
|
|
644
|
+
const makeDeployConfig = (releaseTime: number) => {
|
|
645
|
+
let deployConfig = deepCloneJSON(config);
|
|
646
|
+
deployConfig.parameters.releaseTime = releaseTime;
|
|
647
|
+
deployConfig.parameters.overlapTime = overlap;
|
|
648
|
+
return deployConfig;
|
|
649
|
+
};
|
|
650
|
+
// The switch time is inferred from the overlap, unless one is explicitly set (in which case the overlap only controls when the new instances start)
|
|
651
|
+
let effectiveSwitchTime = scheduledTime || now + overlap;
|
|
652
|
+
return <div className={css.vbox(10).pad2(12).fillWidth.bord2(45, 70, 45).hsl(45, 60, 95)}>
|
|
575
653
|
<div className={css.boldStyle.fontSize(15)}>
|
|
576
|
-
|
|
654
|
+
✏️ Edited (only in this browser, until deployed)
|
|
577
655
|
</div>
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
this.runControllerAction(() => controller.cancelSwitchover.promise(selectedServiceId));
|
|
590
|
-
}}>
|
|
591
|
-
Cancel Switchover
|
|
592
|
-
</button>
|
|
593
|
-
</div>
|
|
594
|
-
</div>}
|
|
595
|
-
{hasPendingConfig && !switchover && <div className={css.vbox(8)}>
|
|
596
|
-
<div className={css.hbox(12).wrap}>
|
|
656
|
+
<ParametersDiff base={liveParameters} next={config.parameters} />
|
|
657
|
+
<div className={css.hbox(12).wrap}>
|
|
658
|
+
<button
|
|
659
|
+
className={css.pad2(12, 8).button.bord2(0, 0, 20).hsl(120, 70, 85).fontWeight("bold")}
|
|
660
|
+
disabled={this.state.isDeploying}
|
|
661
|
+
onClick={() => {
|
|
662
|
+
this.deployConfig(makeDeployConfig(scheduledTime || Date.now() + overlap));
|
|
663
|
+
}}>
|
|
664
|
+
{this.state.isDeploying && "⏳ Scheduling..." || scheduledDeploy && "🔁 Replace Scheduled Deploy" || "🚀 Schedule Deploy"}
|
|
665
|
+
</button>
|
|
666
|
+
<div className={css.opacity(scheduledTime && 1 || 0.5)}>
|
|
597
667
|
<InputLabel
|
|
598
|
-
label=
|
|
599
|
-
|
|
600
|
-
value={
|
|
668
|
+
label="Switch at"
|
|
669
|
+
isDatetime
|
|
670
|
+
value={effectiveSwitchTime}
|
|
601
671
|
onChangeValue={value => {
|
|
602
|
-
this.state.
|
|
672
|
+
this.state.switchTime = Number(value) || 0;
|
|
603
673
|
}}
|
|
604
674
|
/>
|
|
605
|
-
<span>Effective overlap: {formatTime(overlap)}</span>
|
|
606
675
|
</div>
|
|
607
|
-
<
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
}));
|
|
617
|
-
}}>
|
|
618
|
-
🚀 Deploy Now (switch in {formatTime(overlap)})
|
|
619
|
-
</button>
|
|
676
|
+
<span>(in {formatTime(effectiveSwitchTime - now)})</span>
|
|
677
|
+
{!!scheduledTime && <button
|
|
678
|
+
className={css.pad2(12, 8).button.bord2(0, 0, 20).hsl(0, 0, 90)}
|
|
679
|
+
onClick={() => {
|
|
680
|
+
this.state.switchTime = 0;
|
|
681
|
+
}}>
|
|
682
|
+
↺ Reset to overlap-based time
|
|
683
|
+
</button>}
|
|
684
|
+
<div className={css.opacity(scheduledTime && 0.5 || 1)}>
|
|
620
685
|
<InputLabel
|
|
621
|
-
label="
|
|
622
|
-
|
|
623
|
-
value={
|
|
686
|
+
label="Overlap (seconds)"
|
|
687
|
+
integer
|
|
688
|
+
value={overlapSeconds}
|
|
624
689
|
onChangeValue={value => {
|
|
625
|
-
this.state.
|
|
690
|
+
this.state.overlapOverride = Number(value) || 0;
|
|
626
691
|
}}
|
|
627
692
|
/>
|
|
628
|
-
{!!scheduledTime && <span>
|
|
629
|
-
{scheduledTime > now && `in ${formatTime(scheduledTime - now)}` || "in the past!"}
|
|
630
|
-
</span>}
|
|
631
|
-
{notEnoughTime && <span className={css.colorhsl(35, 85, 40).fontWeight("bold")}>
|
|
632
|
-
⚠️ Not enough time for the full overlap ({formatTime(overlap)}) — the new instances won't be fully started before the old ones shut down
|
|
633
|
-
</span>}
|
|
634
|
-
<button
|
|
635
|
-
className={css.pad2(12, 8).button.bord2(0, 0, 20).hsl(120, 70, 90)}
|
|
636
|
-
disabled={!scheduledTime}
|
|
637
|
-
onClick={() => {
|
|
638
|
-
if (!scheduledTime) return;
|
|
639
|
-
this.runControllerAction(() => controller.scheduleSwitchover.promise({
|
|
640
|
-
serviceId: selectedServiceId,
|
|
641
|
-
switchTime: scheduledTime,
|
|
642
|
-
overlapTime: overlap,
|
|
643
|
-
}));
|
|
644
|
-
}}>
|
|
645
|
-
🕐 Schedule Switchover
|
|
646
|
-
</button>
|
|
647
|
-
</div>
|
|
648
|
-
<div className={css.hbox(8)}>
|
|
649
|
-
<button
|
|
650
|
-
className={css.pad2(12, 8).button.bord2(0, 0, 20).hsl(50, 80, 60)}
|
|
651
|
-
onClick={() => {
|
|
652
|
-
this.runControllerAction(() => controller.discardPendingServiceConfig.promise(selectedServiceId));
|
|
653
|
-
}}>
|
|
654
|
-
Discard Pending Config
|
|
655
|
-
</button>
|
|
656
693
|
</div>
|
|
657
|
-
</div>
|
|
694
|
+
</div>
|
|
695
|
+
{!!scheduledTime && scheduledTime <= now && <span className={css.colorhsl(0, 85, 45).fontWeight("bold")}>
|
|
696
|
+
⚠️ The switch time is in the past — deploying switches immediately
|
|
697
|
+
</span>}
|
|
698
|
+
{notEnoughTime && <span className={css.colorhsl(35, 85, 40).fontWeight("bold")}>
|
|
699
|
+
⚠️ The switch time is sooner than the overlap ({formatTime(overlap)}) — the new instances won't be fully started before the old ones shut down
|
|
700
|
+
</span>}
|
|
701
|
+
<div className={css.hbox(8)}>
|
|
702
|
+
<button
|
|
703
|
+
className={css.pad2(12, 8).button.bord2(0, 70, 40).hsl(0, 70, 90)}
|
|
704
|
+
disabled={this.state.isDeploying}
|
|
705
|
+
onClick={() => {
|
|
706
|
+
this.confirmForceDeployNow(makeDeployConfig(0));
|
|
707
|
+
}}>
|
|
708
|
+
⚡ Force Deploy Now
|
|
709
|
+
</button>
|
|
710
|
+
</div>
|
|
658
711
|
</div>;
|
|
659
712
|
})()}
|
|
660
713
|
|
|
@@ -668,11 +721,84 @@ export class ServiceDetailPage extends qreact.Component {
|
|
|
668
721
|
<TypedConfigEditor
|
|
669
722
|
value={config}
|
|
670
723
|
onValueChange={(newValue) => {
|
|
671
|
-
this.
|
|
724
|
+
this.updateEditorState(newValue as ServiceConfig);
|
|
672
725
|
}}
|
|
673
726
|
typeDefinition={serviceConfigType}
|
|
674
727
|
sizeClassName={css.size(1000, 600)}
|
|
675
728
|
/>
|
|
729
|
+
{hasUnsavedChanges && <button
|
|
730
|
+
className={css.pad2(12, 8).button.bord2(0, 0, 20).hsl(50, 80, 50)}
|
|
731
|
+
onClick={() => {
|
|
732
|
+
this.state.editorState = undefined;
|
|
733
|
+
}}>
|
|
734
|
+
Discard Changes
|
|
735
|
+
</button>}
|
|
676
736
|
</div>;
|
|
677
737
|
}
|
|
678
|
-
}
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// An inline diff of the live parameters vs the parameters in the editor / scheduled to deploy, so it's clear exactly what deploying will change.
|
|
741
|
+
class ParametersDiff extends qreact.Component<{ base: ServiceParameters; next: ServiceParameters }> {
|
|
742
|
+
state = t.state({
|
|
743
|
+
showRaw: t.type(false),
|
|
744
|
+
});
|
|
745
|
+
render() {
|
|
746
|
+
let baseValues = new Map<string, unknown>();
|
|
747
|
+
let nextValues = new Map<string, unknown>();
|
|
748
|
+
flattenConfigValues(this.props.base, "", baseValues);
|
|
749
|
+
flattenConfigValues(this.props.next, "", nextValues);
|
|
750
|
+
let keys = Array.from(new Set([...baseValues.keys(), ...nextValues.keys()]));
|
|
751
|
+
sort(keys, x => x);
|
|
752
|
+
let changed = keys.filter(key =>
|
|
753
|
+
// The release time is displayed separately, so it would just be noise
|
|
754
|
+
key !== "releaseTime"
|
|
755
|
+
&& JSON.stringify(baseValues.get(key)) !== JSON.stringify(nextValues.get(key))
|
|
756
|
+
);
|
|
757
|
+
return <div className={css.vbox(4).fillWidth}>
|
|
758
|
+
{changed.length === 0 && <div className={css.colorhsl(0, 0, 50)}>(no parameter changes from the live parameters)</div> || <div className={css.vbox(2).fontFamily("monospace").fontSize(13)}>
|
|
759
|
+
{changed.map(key => <div className={css.hbox(6).wrap}>
|
|
760
|
+
<span className={css.boldStyle}>{key}:</span>
|
|
761
|
+
{baseValues.has(key) && <span className={css.colorhsl(0, 70, 40).textDecoration("line-through")}>{formatDiffValue(baseValues.get(key))}</span>}
|
|
762
|
+
{nextValues.has(key) && <span className={css.colorhsl(120, 60, 30)}>{formatDiffValue(nextValues.get(key))}</span> || <span className={css.colorhsl(0, 70, 40)}>(removed)</span>}
|
|
763
|
+
</div>)}
|
|
764
|
+
</div>}
|
|
765
|
+
<div className={css.hbox(8)}>
|
|
766
|
+
<button
|
|
767
|
+
className={css.pad2(8, 4).button.bord2(0, 0, 30).hsl(0, 0, 92)}
|
|
768
|
+
onClick={() => this.state.showRaw = !this.state.showRaw}>
|
|
769
|
+
{this.state.showRaw && "🧾 Hide Raw Parameters" || "🧾 Show Raw Parameters"}
|
|
770
|
+
</button>
|
|
771
|
+
</div>
|
|
772
|
+
{this.state.showRaw && <div className={css.hbox(24).wrap.alignItems("start")}>
|
|
773
|
+
<div className={css.vbox(2)}>
|
|
774
|
+
<div className={css.boldStyle}>Live</div>
|
|
775
|
+
<div className={css.fontFamily("monospace").fontSize(12).whiteSpace("pre-wrap")}>{JSON.stringify(this.props.base, undefined, 2)}</div>
|
|
776
|
+
</div>
|
|
777
|
+
<div className={css.vbox(2)}>
|
|
778
|
+
<div className={css.boldStyle}>New</div>
|
|
779
|
+
<div className={css.fontFamily("monospace").fontSize(12).whiteSpace("pre-wrap")}>{JSON.stringify(this.props.next, undefined, 2)}</div>
|
|
780
|
+
</div>
|
|
781
|
+
</div>}
|
|
782
|
+
</div>;
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
// The save time is set by the server on every save, so it would make every editor value read as changed
|
|
786
|
+
function configCompareKey(config: ServiceConfig): string {
|
|
787
|
+
return JSON.stringify({ ...config, info: { ...config.info, lastUpdatedTime: 0 } });
|
|
788
|
+
}
|
|
789
|
+
function flattenConfigValues(obj: unknown, prefix: string, out: Map<string, unknown>) {
|
|
790
|
+
if (obj && typeof obj === "object" && !Array.isArray(obj)) {
|
|
791
|
+
for (let [key, value] of Object.entries(obj as { [key: string]: unknown })) {
|
|
792
|
+
flattenConfigValues(value, prefix && prefix + "." + key || key, out);
|
|
793
|
+
}
|
|
794
|
+
return;
|
|
795
|
+
}
|
|
796
|
+
out.set(prefix, obj);
|
|
797
|
+
}
|
|
798
|
+
function formatDiffValue(value: unknown): string {
|
|
799
|
+
let result = JSON.stringify(value);
|
|
800
|
+
if (result === undefined) {
|
|
801
|
+
return "undefined";
|
|
802
|
+
}
|
|
803
|
+
return result;
|
|
804
|
+
}
|