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.
@@ -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
- unsavedChanges: t.type<ServiceConfig | undefined>(undefined),
35
- isSaving: t.type(false),
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
- // Milliseconds; 0 means use the service's configured overlapTime
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 updateUnsavedChanges(updatedConfig: ServiceConfig) {
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.unsavedChanges = updatedConfig;
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
- // Saves to the PENDING config, which only takes effect once a switchover is deployed / scheduled.
116
- private async updateConfig(updatedConfig: ServiceConfig): Promise<void> {
117
- const selectedServiceId = selectedServiceIdParam.value;
118
- if (!selectedServiceId) return;
119
-
120
- Querysub.commit(() => {
121
- // Do not let them update the serviceId, as that would break things
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()).setPendingServiceConfigs.promise([updatedConfig]);
130
- } catch (error) {
131
- Querysub.localCommit(() => {
132
- this.state.saveError = error instanceof Error ? error.message : String(error);
133
- this.state.unsavedChanges = undefined;
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.localCommit(() => {
137
- this.state.unsavedChanges = undefined;
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
- // The editor shows unsaved edits, else the saved-but-not-deployed pending config, else the live config
174
- const config = this.state.unsavedChanges || pendingConfig || originalConfig;
175
- const configT = config;
176
- const hasUnsavedChanges = !!this.state.unsavedChanges;
177
- const hasPendingConfig = !!pendingConfig;
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 machineStatuses = config.machineIds.map(machineId => {
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 = Date.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
- configT.info.title = value;
248
- this.updateUnsavedChanges(configT);
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
- configT.parameters.key = value;
256
- this.updateUnsavedChanges(configT);
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={config.machineIds}
300
+ picked={machineIds}
264
301
  addPicked={machineId => {
265
- configT.machineIds.push(machineId);
266
- this.updateUnsavedChanges(configT);
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 index = configT.machineIds.indexOf(machineId);
307
+ let updated = deepCloneJSON(config);
308
+ let updatedMachineIds = updated.parameters.machineIds || [];
309
+ let index = updatedMachineIds.indexOf(machineId);
270
310
  if (index !== -1) {
271
- configT.machineIds.splice(index, 1);
311
+ updatedMachineIds.splice(index, 1);
272
312
  }
273
- this.updateUnsavedChanges(configT);
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
- {configT.parameters.deploy && <div className={css.vbox(8).fillWidth}>
278
- <h3>Deployed Machines ({config.machineIds.length})</h3>
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
- {switchover && (
328
- <div className={css.colorhsl(35, 80, 40).fontWeight("bold")} title={formatVeryNiceDateTime(switchover.switchTime)}>
329
- ⏻ Shuts down in {formatTime(switchover.switchTime - now)}
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
- + (configT.parameters.deploy ? css.hsl(0, 70, 90) : css.hsl(120, 70, 90))}
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
- const newDeployStatus = !config.parameters.deploy;
447
-
448
- configT.parameters.deploy = newDeployStatus;
449
- void this.updateUnsavedChanges(configT);
497
+ let updated = deepCloneJSON(config);
498
+ updated.parameters.deploy = !config.parameters.deploy;
499
+ this.updateEditorState(updated);
450
500
  }}>
451
- {configT.parameters.deploy ? "⏸️ Disable" : "🚀 Enable"}
501
+ {config.parameters.deploy ? "⏸️ Disable" : "🚀 Enable"}
452
502
  </button>
453
503
 
454
- <button className={css.pad2(12, 8).button.bord2(0, 0, 20).hsl(0, 0, 80)}
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
- // Poke bypasses the pending flow, as it exists to force an immediate restart of the LIVE config
457
- let live = deepCloneJSON(originalConfig);
458
- live.parameters.poke = (live.parameters.poke || 0) + 1;
459
- this.runControllerAction(() => controller.setServiceConfigs.promise([live]));
508
+ const oldParameters = originalConfig.oldParameters;
509
+ if (!oldParameters) return;
510
+ this.updateEditorState({
511
+ ...stripReleaseState(originalConfig),
512
+ parameters: { ...oldParameters, releaseTime: undefined },
513
+ });
460
514
  }}>
461
- Poke
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={async () => {
492
- configT.parameters.gitRef = gitInfo.latestRef;
493
- this.updateUnsavedChanges(configT);
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.unsavedChanges = undefined;
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 "${configT.info.title}" and remove it from all machines.\n\nThis action cannot be undone. Are you sure you want to continue?`);
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
- {(hasPendingConfig || switchover) && (() => {
571
- let overlap = this.state.overlapOverride || config.parameters.overlapTime || DEFAULT_OVERLAP_TIME;
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
- return <div className={css.vbox(10).pad2(12).fillWidth.bord2(210, 60, 50).hsl(210, 40, 95)}>
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
- {switchover && "🕐 Switchover scheduled" || "📝 Pending changes saved (not deployed)"}
654
+ ✏️ Edited (only in this browser, until deployed)
577
655
  </div>
578
- {switchover && <div className={css.vbox(4)}>
579
- <div>
580
- Old instances shut down at <b>{formatVeryNiceDateTime(switchover.switchTime)}</b> (in {formatTime(switchover.switchTime - now)}), overlap {formatTime(switchover.overlapTime)}
581
- </div>
582
- <div>
583
- New instances start at <b>{formatVeryNiceDateTime(switchover.switchTime - switchover.overlapTime)}</b> {switchover.switchTime - switchover.overlapTime <= now && "(already running)" || `(in ${formatTime(switchover.switchTime - switchover.overlapTime - now)})`}
584
- </div>
585
- <div className={css.hbox(8)}>
586
- <button
587
- className={css.pad2(12, 8).button.bord2(0, 0, 20).hsl(0, 70, 90)}
588
- onClick={() => {
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={`Overlap (ms, default ${formatTime(config.parameters.overlapTime || DEFAULT_OVERLAP_TIME)})`}
599
- number
600
- value={this.state.overlapOverride || ""}
668
+ label="Switch at"
669
+ isDatetime
670
+ value={effectiveSwitchTime}
601
671
  onChangeValue={value => {
602
- this.state.overlapOverride = Number(value) || 0;
672
+ this.state.switchTime = Number(value) || 0;
603
673
  }}
604
674
  />
605
- <span>Effective overlap: {formatTime(overlap)}</span>
606
675
  </div>
607
- <div className={css.hbox(12).wrap}>
608
- <button
609
- className={css.pad2(12, 8).button.bord2(0, 0, 20).hsl(120, 70, 85).fontWeight("bold")}
610
- onClick={() => {
611
- let switchTime = Date.now() + overlap;
612
- this.runControllerAction(() => controller.scheduleSwitchover.promise({
613
- serviceId: selectedServiceId,
614
- switchTime,
615
- overlapTime: overlap,
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="Switch at"
622
- isDatetime
623
- value={this.state.switchTime}
686
+ label="Overlap (seconds)"
687
+ integer
688
+ value={overlapSeconds}
624
689
  onChangeValue={value => {
625
- this.state.switchTime = Number(value) || 0;
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.state.unsavedChanges = newValue as ServiceConfig;
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
+ }