querysub 0.510.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,11 +1,11 @@
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
11
  import { deepCloneJSON, nextId, sort, timeInSecond } from "socket-function/src/misc";
@@ -32,8 +32,9 @@ import { showModal } from "../../5-diagnostics/Modal";
32
32
 
33
33
  export class ServiceDetailPage extends qreact.Component {
34
34
  state = t.state({
35
- unsavedChanges: t.type<ServiceConfig | undefined>(undefined),
36
- 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),
37
38
  saveError: t.string,
38
39
  expandedErrors: t.lookup({
39
40
  expanded: t.type(false)
@@ -51,15 +52,12 @@ export class ServiceDetailPage extends qreact.Component {
51
52
 
52
53
 
53
54
 
54
- 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);
55
58
  // Do not let them update the serviceId, as that would break things
56
59
  updatedConfig.serviceId = selectedServiceIdParam.value;
57
- this.state.unsavedChanges = updatedConfig;
58
- }
59
- private updateConfigSynced(updatedConfig: ServiceConfig) {
60
- Querysub.onCommitFinished(() => {
61
- void this.updateConfig(updatedConfig);
62
- });
60
+ this.state.editorState = updatedConfig;
63
61
  }
64
62
 
65
63
  private async startWatchingOutput(config: {
@@ -113,30 +111,23 @@ export class ServiceDetailPage extends qreact.Component {
113
111
  });
114
112
  }
115
113
 
116
- // Saves to the PENDING config, which only takes effect once a switchover is deployed / scheduled.
117
- private async updateConfig(updatedConfig: ServiceConfig): Promise<void> {
118
- const selectedServiceId = selectedServiceIdParam.value;
119
- if (!selectedServiceId) return;
120
-
121
- Querysub.commit(() => {
122
- // Do not let them update the serviceId, as that would break things
123
- updatedConfig.serviceId = selectedServiceIdParam.value;
124
- this.state.isSaving = true;
125
- this.state.saveError = "";
126
- });
127
-
128
- 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 () => {
129
121
  try {
130
- await MachineServiceController(SocketFunction.browserNodeId()).setPendingServiceConfigs.promise([updatedConfig]);
131
- } catch (error) {
132
- Querysub.localCommit(() => {
133
- this.state.saveError = error instanceof Error ? error.message : String(error);
134
- 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;
135
127
  });
136
128
  } finally {
137
- Querysub.localCommit(() => {
138
- this.state.unsavedChanges = undefined;
139
- this.state.isSaving = false;
129
+ Querysub.commit(() => {
130
+ this.state.isDeploying = false;
140
131
  });
141
132
  }
142
133
  });
@@ -150,25 +141,25 @@ export class ServiceDetailPage extends qreact.Component {
150
141
  Querysub.localCommit(() => {
151
142
  this.state.saveError = error instanceof Error ? error.message : String(error);
152
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);
153
147
  }
154
148
  });
155
149
  }
156
150
 
157
- private confirmForceDeployNow(serviceId: string, overlapTime: number) {
151
+ private confirmForceDeployNow(deployConfig: ServiceConfig) {
158
152
  let modal: { close: () => void } | undefined;
159
153
  modal = showModal({
160
154
  content: <div className={css.fixed.pos(0, 0).size("100vw", "100vh").hsla(0, 0, 0, 0.5).display("flex").alignItems("center").justifyContent("center")}>
161
155
  <div className={css.vbox(12).pad2(20).hsl(0, 0, 98).bord2(0, 0, 40) + " keepModalsOpen"}>
162
156
  <div className={css.boldStyle.fontSize(16)}>⚡ Force deploy now?</div>
163
- <div>The pending config becomes live immediately. The old instances are shut down right away, with no overlap.</div>
157
+ <div>This config becomes live immediately. The old instances are shut down right away, with no overlap.</div>
164
158
  <div className={css.hbox(10)}>
165
159
  <Button hue={0} onClick={() => {
166
160
  modal && modal.close();
167
- this.runControllerAction(() => MachineServiceController(SocketFunction.browserNodeId()).scheduleSwitchover.promise({
168
- serviceId,
169
- switchTime: Date.now(),
170
- overlapTime,
171
- }));
161
+ deployConfig.parameters.releaseTime = undefined;
162
+ this.deployConfig(deployConfig);
172
163
  }}>⚡ Force Deploy Now</Button>
173
164
  <Button onClick={() => modal && modal.close()}>Cancel</Button>
174
165
  </div>
@@ -186,23 +177,33 @@ export class ServiceDetailPage extends qreact.Component {
186
177
  let controller = MachineServiceController(SocketFunction.browserNodeId());
187
178
  const originalConfig = controller.getServiceConfig(selectedServiceId);
188
179
  let serviceConfigType = controller.getServiceConfigType();
189
- let pendingConfig = controller.getPendingServiceConfig(selectedServiceId);
190
- let switchover = controller.getServiceSwitchover(selectedServiceId);
191
180
 
192
181
  if (!originalConfig || !serviceConfigType) {
193
182
  if (controller.isAnyLoading()) return <div>Loading service...</div>;
194
183
  return <div>Service not found</div>;
195
184
  }
196
185
 
197
- // The editor shows unsaved edits, else the saved-but-not-deployed pending config, else the live config
198
- const config = this.state.unsavedChanges || pendingConfig || originalConfig;
199
- const configT = config;
200
- const hasUnsavedChanges = !!this.state.unsavedChanges;
201
- 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);
202
202
 
203
203
  // Sort machines by status and heartbeat
204
204
  let nextIndexes = new Map<string, number>();
205
- let machineStatuses = config.machineIds.map(machineId => {
205
+ let machineIds = config.parameters.machineIds || [];
206
+ let machineStatuses = machineIds.map(machineId => {
206
207
  let index = nextIndexes.get(machineId) || 0;
207
208
  nextIndexes.set(machineId, index + 1);
208
209
  let machineInfo = controller.getMachineInfo(machineId);
@@ -268,38 +269,64 @@ export class ServiceDetailPage extends qreact.Component {
268
269
  fillWidth
269
270
  value={config.info.title}
270
271
  onChangeValue={value => {
271
- configT.info.title = value;
272
- this.updateUnsavedChanges(configT);
272
+ config.info.title = value;
273
+ this.updateEditorState(config);
273
274
  }}
274
275
  />
275
276
  <InputLabel
276
277
  label="Key"
277
278
  value={config.parameters.key}
278
279
  onChangeValue={value => {
279
- configT.parameters.key = value;
280
- this.updateUnsavedChanges(configT);
280
+ config.parameters.key = value;
281
+ this.updateEditorState(config);
281
282
  }}
282
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>
283
296
  </div>
284
297
 
285
298
  <MachinePicker
286
299
  label="Machines"
287
- picked={config.machineIds}
300
+ picked={machineIds}
288
301
  addPicked={machineId => {
289
- configT.machineIds.push(machineId);
290
- this.updateUnsavedChanges(configT);
302
+ let updated = deepCloneJSON(config);
303
+ updated.parameters.machineIds = [...(updated.parameters.machineIds || []), machineId];
304
+ this.updateEditorState(updated);
291
305
  }}
292
306
  removePicked={machineId => {
293
- let index = configT.machineIds.indexOf(machineId);
307
+ let updated = deepCloneJSON(config);
308
+ let updatedMachineIds = updated.parameters.machineIds || [];
309
+ let index = updatedMachineIds.indexOf(machineId);
294
310
  if (index !== -1) {
295
- configT.machineIds.splice(index, 1);
311
+ updatedMachineIds.splice(index, 1);
296
312
  }
297
- 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);
298
325
  }}
299
326
  />
300
327
  {/* Machine Status */}
301
- {configT.parameters.deploy && <div className={css.vbox(8).fillWidth}>
302
- <h3>Deployed Machines ({config.machineIds.length})</h3>
328
+ {config.parameters.deploy && <div className={css.vbox(8).fillWidth}>
329
+ <h3>Deployed Machines ({machineIds.length})</h3>
303
330
  <div className={css.vbox(4).fillWidth}>
304
331
  {machineStatuses.map(({ machineId, machineInfo, serviceInfo, isMachineDead, hasError, isDisabled, index }) => {
305
332
  if (!machineInfo) return <div key={machineId}>Loading {machineId}...</div>;
@@ -348,9 +375,9 @@ export class ServiceDetailPage extends qreact.Component {
348
375
  <div title={formatVeryNiceDateTime(machineInfo.heartbeat)}>
349
376
  Heartbeat: {formatTime(now - machineInfo.heartbeat)}
350
377
  </div>
351
- {switchover && (
352
- <div className={css.colorhsl(35, 80, 40).fontWeight("bold")} title={formatVeryNiceDateTime(switchover.switchTime)}>
353
- ⏻ 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)}
354
381
  </div>
355
382
  )}
356
383
  {serviceInfo && (
@@ -463,58 +490,38 @@ export class ServiceDetailPage extends qreact.Component {
463
490
 
464
491
  <div className={css.hbox(12).fillWidth}>
465
492
  <button className={css.pad2(12, 8).button.bord2(0, 0, 20)
466
- + (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))}
467
494
  onClick={() => {
468
495
  if (!selectedServiceId || !config) return;
469
496
 
470
- const newDeployStatus = !config.parameters.deploy;
471
-
472
- configT.parameters.deploy = newDeployStatus;
473
- void this.updateUnsavedChanges(configT);
497
+ let updated = deepCloneJSON(config);
498
+ updated.parameters.deploy = !config.parameters.deploy;
499
+ this.updateEditorState(updated);
474
500
  }}>
475
- {configT.parameters.deploy ? "⏸️ Disable" : "🚀 Enable"}
501
+ {config.parameters.deploy ? "⏸️ Disable" : "🚀 Enable"}
476
502
  </button>
477
503
 
478
- <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"
479
507
  onClick={() => {
480
- // Poke bypasses the pending flow, as it exists to force an immediate restart of the LIVE config
481
- let live = deepCloneJSON(originalConfig);
482
- live.parameters.poke = (live.parameters.poke || 0) + 1;
483
- 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
+ });
484
514
  }}>
485
- Poke
486
- </button>
487
-
488
- {hasUnsavedChanges && <>
489
- <Button
490
- flavor="noui"
491
- hotkeys={["global+ctrl+s"]}
492
- showHotkeys
493
- className={
494
- css.pad2(12, 8).button.bord2(0, 0, 20).hbox(0).hsl(120, 70, 90)
495
- }
496
- onClick={() => {
497
- if (!hasUnsavedChanges || !selectedServiceId || !this.state.unsavedChanges) return;
498
- this.updateConfigSynced(this.state.unsavedChanges);
499
- }
500
- }>
501
- {this.state.isSaving ? "Saving..." : "Save Pending"}
502
- </Button>
503
- <button
504
- className={css.pad2(12, 8).button.bord2(0, 0, 20).hsl(50, 80, 50)}
505
- onClick={() => {
506
- this.state.unsavedChanges = undefined;
507
- }}>
508
- Discard Changes
509
- </button>
510
- </>}
515
+ ↩️ Revert to Previous Deploy
516
+ </button>}
511
517
 
512
518
  {gitInfo?.repoUrl === config.parameters.repoUrl && gitInfo.latestRef !== config.parameters.gitRef &&
513
519
  <button
514
520
  className={buttonStyle.hsl(120, 70, 90).alignSelf("stretch")}
515
- onClick={async () => {
516
- configT.parameters.gitRef = gitInfo.latestRef;
517
- this.updateUnsavedChanges(configT);
521
+ onClick={() => {
522
+ let updated = deepCloneJSON(config);
523
+ updated.parameters.gitRef = gitInfo.latestRef;
524
+ this.updateEditorState(updated);
518
525
  }}
519
526
  >
520
527
  <b>Update to New</b>
@@ -529,10 +536,6 @@ export class ServiceDetailPage extends qreact.Component {
529
536
  </button>
530
537
  }
531
538
 
532
- {this.state.isSaving && <div className={css.pad2(12, 8).bord2(0, 0, 20) + " rainbow-flash"}>
533
- Saving pending changes
534
- </div>}
535
-
536
539
  <div className={css.marginAuto}></div>
537
540
 
538
541
  <button className={css.pad2(12, 8).button.bord2(0, 0, 20).hsl(110, 70, 90)}
@@ -550,7 +553,7 @@ export class ServiceDetailPage extends qreact.Component {
550
553
  await controller.addServiceConfig.promise(newServiceId, clonedConfig);
551
554
  Querysub.commit(() => {
552
555
  selectedServiceIdParam.value = newServiceId;
553
- this.state.unsavedChanges = undefined;
556
+ this.state.editorState = undefined;
554
557
  });
555
558
  } catch (error) {
556
559
  Querysub.localCommit(() => {
@@ -566,7 +569,7 @@ export class ServiceDetailPage extends qreact.Component {
566
569
  onClick={() => {
567
570
  if (!selectedServiceId) return;
568
571
 
569
- 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?`);
570
573
  if (!confirmed) return;
571
574
 
572
575
  Querysub.onCommitFinished(async () => {
@@ -580,10 +583,6 @@ export class ServiceDetailPage extends qreact.Component {
580
583
  Querysub.localCommit(() => {
581
584
  this.state.saveError = error instanceof Error ? error.message : String(error);
582
585
  });
583
- } finally {
584
- Querysub.localCommit(() => {
585
- this.state.isSaving = false;
586
- });
587
586
  }
588
587
  });
589
588
  }}>
@@ -591,102 +590,124 @@ export class ServiceDetailPage extends qreact.Component {
591
590
  </button>
592
591
  </div>
593
592
 
594
- {(hasPendingConfig || switchover) && (() => {
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
+ }
595
640
  let overlapSeconds = this.state.overlapOverride || Math.round((config.parameters.overlapTime || DEFAULT_OVERLAP_TIME) / timeInSecond);
596
641
  let overlap = overlapSeconds * timeInSecond;
597
642
  let scheduledTime = this.state.switchTime;
598
643
  let notEnoughTime = !!scheduledTime && scheduledTime > now && scheduledTime - now < overlap;
599
- return <div className={css.vbox(10).pad2(12).fillWidth.bord2(210, 60, 50).hsl(210, 40, 95)}>
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)}>
600
653
  <div className={css.boldStyle.fontSize(15)}>
601
- {switchover && "🕐 Switchover scheduled" || "📝 Pending changes saved (not deployed)"}
654
+ ✏️ Edited (only in this browser, until deployed)
602
655
  </div>
603
- {hasPendingConfig && <ConfigDiff base={originalConfig} next={config} />}
604
- {switchover && <div className={css.vbox(4)}>
605
- <div>
606
- Old instances shut down at <b>{formatVeryNiceDateTime(switchover.switchTime)}</b> (in {formatTime(switchover.switchTime - now)}), overlap {formatTime(switchover.overlapTime)}
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)}>
667
+ <InputLabel
668
+ label="Switch at"
669
+ isDatetime
670
+ value={effectiveSwitchTime}
671
+ onChangeValue={value => {
672
+ this.state.switchTime = Number(value) || 0;
673
+ }}
674
+ />
607
675
  </div>
608
- <div>
609
- 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)})`}
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)}>
685
+ <InputLabel
686
+ label="Overlap (seconds)"
687
+ integer
688
+ value={overlapSeconds}
689
+ onChangeValue={value => {
690
+ this.state.overlapOverride = Number(value) || 0;
691
+ }}
692
+ />
610
693
  </div>
611
- <div className={css.hbox(8)}>
612
- <button
613
- className={css.pad2(12, 8).button.bord2(0, 0, 20).hsl(0, 70, 90)}
614
- onClick={() => {
615
- this.runControllerAction(() => controller.cancelSwitchover.promise(selectedServiceId));
616
- }}>
617
- Cancel Switchover
618
- </button>
619
- </div>
620
- </div>}
621
- {hasPendingConfig && !switchover && (() => {
622
- // 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)
623
- let effectiveSwitchTime = scheduledTime || now + overlap;
624
- return <div className={css.vbox(8)}>
625
- <div className={css.hbox(12).wrap}>
626
- <button
627
- className={css.pad2(12, 8).button.bord2(0, 0, 20).hsl(120, 70, 85).fontWeight("bold")}
628
- onClick={() => {
629
- this.runControllerAction(() => controller.scheduleSwitchover.promise({
630
- serviceId: selectedServiceId,
631
- switchTime: scheduledTime || Date.now() + overlap,
632
- overlapTime: overlap,
633
- }));
634
- }}>
635
- 🚀 Deploy
636
- </button>
637
- <div className={css.opacity(scheduledTime && 1 || 0.5)}>
638
- <InputLabel
639
- label="Switch at"
640
- isDatetime
641
- value={effectiveSwitchTime}
642
- onChangeValue={value => {
643
- this.state.switchTime = Number(value) || 0;
644
- }}
645
- />
646
- </div>
647
- <span>(in {formatTime(effectiveSwitchTime - now)})</span>
648
- {!!scheduledTime && <button
649
- className={css.pad2(12, 8).button.bord2(0, 0, 20).hsl(0, 0, 90)}
650
- onClick={() => {
651
- this.state.switchTime = 0;
652
- }}>
653
- ↺ Reset to overlap-based time
654
- </button>}
655
- <div className={css.opacity(scheduledTime && 0.5 || 1)}>
656
- <InputLabel
657
- label="Overlap (seconds)"
658
- integer
659
- value={overlapSeconds}
660
- onChangeValue={value => {
661
- this.state.overlapOverride = Number(value) || 0;
662
- }}
663
- />
664
- </div>
665
- </div>
666
- {!!scheduledTime && scheduledTime <= now && <span className={css.colorhsl(0, 85, 45).fontWeight("bold")}>
667
- ⚠️ The switch time is in the past — deploying switches immediately
668
- </span>}
669
- {notEnoughTime && <span className={css.colorhsl(35, 85, 40).fontWeight("bold")}>
670
- ⚠️ The switch time is sooner than the overlap ({formatTime(overlap)}) — the new instances won't be fully started before the old ones shut down
671
- </span>}
672
- <div className={css.hbox(8)}>
673
- <button
674
- className={css.pad2(12, 8).button.bord2(0, 70, 40).hsl(0, 70, 90)}
675
- onClick={() => {
676
- this.confirmForceDeployNow(selectedServiceId, overlap);
677
- }}>
678
- ⚡ Force Deploy Now
679
- </button>
680
- <button
681
- className={css.pad2(12, 8).button.bord2(0, 0, 20).hsl(50, 80, 60)}
682
- onClick={() => {
683
- this.runControllerAction(() => controller.discardPendingServiceConfig.promise(selectedServiceId));
684
- }}>
685
- Discard Pending Config
686
- </button>
687
- </div>
688
- </div>;
689
- })()}
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>
690
711
  </div>;
691
712
  })()}
692
713
 
@@ -700,17 +721,27 @@ export class ServiceDetailPage extends qreact.Component {
700
721
  <TypedConfigEditor
701
722
  value={config}
702
723
  onValueChange={(newValue) => {
703
- this.state.unsavedChanges = newValue as ServiceConfig;
724
+ this.updateEditorState(newValue as ServiceConfig);
704
725
  }}
705
726
  typeDefinition={serviceConfigType}
706
727
  sizeClassName={css.size(1000, 600)}
707
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>}
708
736
  </div>;
709
737
  }
710
738
  }
711
739
 
712
- // An inline diff of the live config vs the config being deployed, so it's clear exactly what a switchover will change.
713
- class ConfigDiff extends qreact.Component<{ base: ServiceConfig; next: ServiceConfig }> {
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
+ });
714
745
  render() {
715
746
  let baseValues = new Map<string, unknown>();
716
747
  let nextValues = new Map<string, unknown>();
@@ -719,22 +750,42 @@ class ConfigDiff extends qreact.Component<{ base: ServiceConfig; next: ServiceCo
719
750
  let keys = Array.from(new Set([...baseValues.keys(), ...nextValues.keys()]));
720
751
  sort(keys, x => x);
721
752
  let changed = keys.filter(key =>
722
- // The save time always changes on save, so it would just be noise
723
- key !== "info.lastUpdatedTime"
753
+ // The release time is displayed separately, so it would just be noise
754
+ key !== "releaseTime"
724
755
  && JSON.stringify(baseValues.get(key)) !== JSON.stringify(nextValues.get(key))
725
756
  );
726
- if (changed.length === 0) {
727
- return <div className={css.colorhsl(0, 0, 50)}>(no changes from the live config)</div>;
728
- }
729
- return <div className={css.vbox(2).fontFamily("monospace").fontSize(13)}>
730
- {changed.map(key => <div className={css.hbox(6).wrap}>
731
- <span className={css.boldStyle}>{key}:</span>
732
- {baseValues.has(key) && <span className={css.colorhsl(0, 70, 40).textDecoration("line-through")}>{formatDiffValue(baseValues.get(key))}</span>}
733
- {nextValues.has(key) && <span className={css.colorhsl(120, 60, 30)}>{formatDiffValue(nextValues.get(key))}</span> || <span className={css.colorhsl(0, 70, 40)}>(removed)</span>}
734
- </div>)}
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>}
735
782
  </div>;
736
783
  }
737
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
+ }
738
789
  function flattenConfigValues(obj: unknown, prefix: string, out: Map<string, unknown>) {
739
790
  if (obj && typeof obj === "object" && !Array.isArray(obj)) {
740
791
  for (let [key, value] of Object.entries(obj as { [key: string]: unknown })) {