querysub 0.510.0 → 0.512.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
  });
@@ -147,28 +138,25 @@ export class ServiceDetailPage extends qreact.Component {
147
138
  try {
148
139
  await action();
149
140
  } catch (error) {
150
- Querysub.localCommit(() => {
141
+ Querysub.commit(() => {
151
142
  this.state.saveError = error instanceof Error ? error.message : String(error);
152
143
  });
153
144
  }
154
145
  });
155
146
  }
156
147
 
157
- private confirmForceDeployNow(serviceId: string, overlapTime: number) {
148
+ private confirmForceDeployNow(deployConfig: ServiceConfig) {
158
149
  let modal: { close: () => void } | undefined;
159
150
  modal = showModal({
160
151
  content: <div className={css.fixed.pos(0, 0).size("100vw", "100vh").hsla(0, 0, 0, 0.5).display("flex").alignItems("center").justifyContent("center")}>
161
152
  <div className={css.vbox(12).pad2(20).hsl(0, 0, 98).bord2(0, 0, 40) + " keepModalsOpen"}>
162
153
  <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>
154
+ <div>This config becomes live immediately. The old instances are shut down right away, with no overlap.</div>
164
155
  <div className={css.hbox(10)}>
165
156
  <Button hue={0} onClick={() => {
166
157
  modal && modal.close();
167
- this.runControllerAction(() => MachineServiceController(SocketFunction.browserNodeId()).scheduleSwitchover.promise({
168
- serviceId,
169
- switchTime: Date.now(),
170
- overlapTime,
171
- }));
158
+ deployConfig.parameters.releaseTime = undefined;
159
+ this.deployConfig(deployConfig);
172
160
  }}>⚡ Force Deploy Now</Button>
173
161
  <Button onClick={() => modal && modal.close()}>Cancel</Button>
174
162
  </div>
@@ -186,23 +174,33 @@ export class ServiceDetailPage extends qreact.Component {
186
174
  let controller = MachineServiceController(SocketFunction.browserNodeId());
187
175
  const originalConfig = controller.getServiceConfig(selectedServiceId);
188
176
  let serviceConfigType = controller.getServiceConfigType();
189
- let pendingConfig = controller.getPendingServiceConfig(selectedServiceId);
190
- let switchover = controller.getServiceSwitchover(selectedServiceId);
191
177
 
192
178
  if (!originalConfig || !serviceConfigType) {
193
179
  if (controller.isAnyLoading()) return <div>Loading service...</div>;
194
180
  return <div>Service not found</div>;
195
181
  }
196
182
 
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;
183
+ // Parameters that were deployed but haven't gone live yet
184
+ const scheduledDeploy = (() => {
185
+ let releaseTime = originalConfig.parameters.releaseTime || 0;
186
+ if (!releaseTime || Date.now() >= releaseTime || !originalConfig.oldParameters) return undefined;
187
+ return {
188
+ switchTime: releaseTime,
189
+ overlapTime: originalConfig.parameters.overlapTime || DEFAULT_OVERLAP_TIME,
190
+ };
191
+ })();
192
+
193
+ // 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).
194
+ const deployedConfig = stripReleaseState(originalConfig);
195
+ const editorState = this.state.editorState && this.state.editorState.serviceId === selectedServiceId && this.state.editorState || undefined;
196
+ const config = editorState || deployedConfig;
197
+ // 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)
198
+ const hasUnsavedChanges = !!editorState && configCompareKey(editorState) !== configCompareKey(deployedConfig);
202
199
 
203
200
  // Sort machines by status and heartbeat
204
201
  let nextIndexes = new Map<string, number>();
205
- let machineStatuses = config.machineIds.map(machineId => {
202
+ let machineIds = config.parameters.machineIds || [];
203
+ let machineStatuses = machineIds.map(machineId => {
206
204
  let index = nextIndexes.get(machineId) || 0;
207
205
  nextIndexes.set(machineId, index + 1);
208
206
  let machineInfo = controller.getMachineInfo(machineId);
@@ -268,38 +266,64 @@ export class ServiceDetailPage extends qreact.Component {
268
266
  fillWidth
269
267
  value={config.info.title}
270
268
  onChangeValue={value => {
271
- configT.info.title = value;
272
- this.updateUnsavedChanges(configT);
269
+ config.info.title = value;
270
+ this.updateEditorState(config);
273
271
  }}
274
272
  />
275
273
  <InputLabel
276
274
  label="Key"
277
275
  value={config.parameters.key}
278
276
  onChangeValue={value => {
279
- configT.parameters.key = value;
280
- this.updateUnsavedChanges(configT);
277
+ config.parameters.key = value;
278
+ this.updateEditorState(config);
281
279
  }}
282
280
  />
281
+ <div className={css.fillWidth}>
282
+ <InputLabel
283
+ label="Notes"
284
+ textarea
285
+ fillWidth
286
+ value={config.info.notes}
287
+ onChangeValue={value => {
288
+ config.info.notes = value;
289
+ this.updateEditorState(config);
290
+ }}
291
+ />
292
+ </div>
283
293
  </div>
284
294
 
285
295
  <MachinePicker
286
296
  label="Machines"
287
- picked={config.machineIds}
297
+ picked={machineIds}
288
298
  addPicked={machineId => {
289
- configT.machineIds.push(machineId);
290
- this.updateUnsavedChanges(configT);
299
+ let updated = deepCloneJSON(config);
300
+ updated.parameters.machineIds = [...(updated.parameters.machineIds || []), machineId];
301
+ this.updateEditorState(updated);
291
302
  }}
292
303
  removePicked={machineId => {
293
- let index = configT.machineIds.indexOf(machineId);
304
+ let updated = deepCloneJSON(config);
305
+ let updatedMachineIds = updated.parameters.machineIds || [];
306
+ let index = updatedMachineIds.indexOf(machineId);
294
307
  if (index !== -1) {
295
- configT.machineIds.splice(index, 1);
308
+ updatedMachineIds.splice(index, 1);
296
309
  }
297
- this.updateUnsavedChanges(configT);
310
+ updated.parameters.machineIds = updatedMachineIds;
311
+ this.updateEditorState(updated);
312
+ }}
313
+ />
314
+ <InputLabel
315
+ label="Deploy overlap (seconds)"
316
+ integer
317
+ value={Math.round((config.parameters.overlapTime || DEFAULT_OVERLAP_TIME) / timeInSecond)}
318
+ onChangeValue={value => {
319
+ let updated = deepCloneJSON(config);
320
+ updated.parameters.overlapTime = (Number(value) || 0) * timeInSecond;
321
+ this.updateEditorState(updated);
298
322
  }}
299
323
  />
300
324
  {/* Machine Status */}
301
- {configT.parameters.deploy && <div className={css.vbox(8).fillWidth}>
302
- <h3>Deployed Machines ({config.machineIds.length})</h3>
325
+ {config.parameters.deploy && <div className={css.vbox(8).fillWidth}>
326
+ <h3>Deployed Machines ({machineIds.length})</h3>
303
327
  <div className={css.vbox(4).fillWidth}>
304
328
  {machineStatuses.map(({ machineId, machineInfo, serviceInfo, isMachineDead, hasError, isDisabled, index }) => {
305
329
  if (!machineInfo) return <div key={machineId}>Loading {machineId}...</div>;
@@ -348,9 +372,9 @@ export class ServiceDetailPage extends qreact.Component {
348
372
  <div title={formatVeryNiceDateTime(machineInfo.heartbeat)}>
349
373
  Heartbeat: {formatTime(now - machineInfo.heartbeat)}
350
374
  </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)}
375
+ {scheduledDeploy && (
376
+ <div className={css.colorhsl(35, 80, 40).fontWeight("bold")} title={formatVeryNiceDateTime(scheduledDeploy.switchTime)}>
377
+ ⏻ Shuts down in {formatTime(scheduledDeploy.switchTime - now)}
354
378
  </div>
355
379
  )}
356
380
  {serviceInfo && (
@@ -463,58 +487,38 @@ export class ServiceDetailPage extends qreact.Component {
463
487
 
464
488
  <div className={css.hbox(12).fillWidth}>
465
489
  <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))}
490
+ + (config.parameters.deploy ? css.hsl(0, 70, 90) : css.hsl(120, 70, 90))}
467
491
  onClick={() => {
468
492
  if (!selectedServiceId || !config) return;
469
493
 
470
- const newDeployStatus = !config.parameters.deploy;
471
-
472
- configT.parameters.deploy = newDeployStatus;
473
- void this.updateUnsavedChanges(configT);
494
+ let updated = deepCloneJSON(config);
495
+ updated.parameters.deploy = !config.parameters.deploy;
496
+ this.updateEditorState(updated);
474
497
  }}>
475
- {configT.parameters.deploy ? "⏸️ Disable" : "🚀 Enable"}
498
+ {config.parameters.deploy ? "⏸️ Disable" : "🚀 Enable"}
476
499
  </button>
477
500
 
478
- <button className={css.pad2(12, 8).button.bord2(0, 0, 20).hsl(0, 0, 80)}
501
+ {originalConfig.oldParameters && <button
502
+ className={css.pad2(12, 8).button.bord2(0, 0, 20).hsl(35, 70, 85)}
503
+ title="Loads the previous deploy's parameters into the editor, so you can schedule them as a new deploy"
479
504
  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]));
505
+ const oldParameters = originalConfig.oldParameters;
506
+ if (!oldParameters) return;
507
+ this.updateEditorState({
508
+ ...stripReleaseState(originalConfig),
509
+ parameters: { ...oldParameters, releaseTime: undefined },
510
+ });
484
511
  }}>
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
- </>}
512
+ ↩️ Revert to Previous Deploy
513
+ </button>}
511
514
 
512
515
  {gitInfo?.repoUrl === config.parameters.repoUrl && gitInfo.latestRef !== config.parameters.gitRef &&
513
516
  <button
514
517
  className={buttonStyle.hsl(120, 70, 90).alignSelf("stretch")}
515
- onClick={async () => {
516
- configT.parameters.gitRef = gitInfo.latestRef;
517
- this.updateUnsavedChanges(configT);
518
+ onClick={() => {
519
+ let updated = deepCloneJSON(config);
520
+ updated.parameters.gitRef = gitInfo.latestRef;
521
+ this.updateEditorState(updated);
518
522
  }}
519
523
  >
520
524
  <b>Update to New</b>
@@ -529,10 +533,6 @@ export class ServiceDetailPage extends qreact.Component {
529
533
  </button>
530
534
  }
531
535
 
532
- {this.state.isSaving && <div className={css.pad2(12, 8).bord2(0, 0, 20) + " rainbow-flash"}>
533
- Saving pending changes
534
- </div>}
535
-
536
536
  <div className={css.marginAuto}></div>
537
537
 
538
538
  <button className={css.pad2(12, 8).button.bord2(0, 0, 20).hsl(110, 70, 90)}
@@ -550,7 +550,7 @@ export class ServiceDetailPage extends qreact.Component {
550
550
  await controller.addServiceConfig.promise(newServiceId, clonedConfig);
551
551
  Querysub.commit(() => {
552
552
  selectedServiceIdParam.value = newServiceId;
553
- this.state.unsavedChanges = undefined;
553
+ this.state.editorState = undefined;
554
554
  });
555
555
  } catch (error) {
556
556
  Querysub.localCommit(() => {
@@ -566,7 +566,7 @@ export class ServiceDetailPage extends qreact.Component {
566
566
  onClick={() => {
567
567
  if (!selectedServiceId) return;
568
568
 
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?`);
569
+ 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
570
  if (!confirmed) return;
571
571
 
572
572
  Querysub.onCommitFinished(async () => {
@@ -580,10 +580,6 @@ export class ServiceDetailPage extends qreact.Component {
580
580
  Querysub.localCommit(() => {
581
581
  this.state.saveError = error instanceof Error ? error.message : String(error);
582
582
  });
583
- } finally {
584
- Querysub.localCommit(() => {
585
- this.state.isSaving = false;
586
- });
587
583
  }
588
584
  });
589
585
  }}>
@@ -591,102 +587,124 @@ export class ServiceDetailPage extends qreact.Component {
591
587
  </button>
592
588
  </div>
593
589
 
594
- {(hasPendingConfig || switchover) && (() => {
590
+ {/* The deploy that is scheduled on the SERVER — completely independent from any editor changes below */}
591
+ {scheduledDeploy && <div className={css.vbox(10).pad2(12).fillWidth.bord2(210, 60, 50).hsl(210, 40, 95)}>
592
+ <div className={css.boldStyle.fontSize(15)}>
593
+ 🕐 Deploy scheduled for {formatTime(scheduledDeploy.switchTime - now)} ({formatDateTimeDetailed(scheduledDeploy.switchTime)})
594
+ </div>
595
+ <ParametersDiff base={getLiveServiceParameters(originalConfig)} next={originalConfig.parameters} />
596
+ <div className={css.vbox(4)}>
597
+ <div>
598
+ 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)})
599
+ </div>
600
+ <div className={css.hbox(8)}>
601
+ <button
602
+ className={css.pad2(12, 8).button.bord2(0, 0, 20).hsl(0, 70, 90)}
603
+ onClick={() => {
604
+ this.runControllerAction(async () => {
605
+ let cancelled = await controller.cancelScheduledDeploy.promise(selectedServiceId);
606
+ // Keep the cancelled changes in the editor, so they aren't lost (Discard Changes drops them)
607
+ Querysub.commit(() => {
608
+ this.state.editorState = cancelled;
609
+ });
610
+ });
611
+ }}>
612
+ Cancel Scheduled Deploy
613
+ </button>
614
+ </div>
615
+ </div>
616
+ </div>}
617
+
618
+ {/* The EDITOR's changes (browser-only) — deploying them schedules a (new) deploy */}
619
+ {(() => {
620
+ if (!hasUnsavedChanges) return undefined;
621
+ let liveParameters = getLiveServiceParameters(originalConfig);
622
+ // Only parameter changes need a release; info-only edits just save immediately
623
+ let parametersChanged = JSON.stringify({ ...config.parameters, releaseTime: undefined }) !== JSON.stringify({ ...liveParameters, releaseTime: undefined });
624
+ if (!parametersChanged) {
625
+ return <div className={css.hbox(12)}>
626
+ <button
627
+ className={css.pad2(12, 8).button.bord2(0, 0, 20).hsl(120, 70, 85).fontWeight("bold")}
628
+ disabled={this.state.isDeploying}
629
+ onClick={() => {
630
+ this.deployConfig(deepCloneJSON(config));
631
+ }}>
632
+ {this.state.isDeploying && "⏳ Saving..." || "💾 Save"}
633
+ </button>
634
+ <span title={`${configCompareKey(config)} !== ${configCompareKey(deployedConfig)}`}>Only the title / notes changed, so this saves immediately</span>
635
+ </div>;
636
+ }
595
637
  let overlapSeconds = this.state.overlapOverride || Math.round((config.parameters.overlapTime || DEFAULT_OVERLAP_TIME) / timeInSecond);
596
638
  let overlap = overlapSeconds * timeInSecond;
597
639
  let scheduledTime = this.state.switchTime;
598
640
  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)}>
641
+ const makeDeployConfig = (releaseTime: number) => {
642
+ let deployConfig = deepCloneJSON(config);
643
+ deployConfig.parameters.releaseTime = releaseTime;
644
+ deployConfig.parameters.overlapTime = overlap;
645
+ return deployConfig;
646
+ };
647
+ // 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)
648
+ let effectiveSwitchTime = scheduledTime || now + overlap;
649
+ return <div className={css.vbox(10).pad2(12).fillWidth.bord2(45, 70, 45).hsl(45, 60, 95)}>
600
650
  <div className={css.boldStyle.fontSize(15)}>
601
- {switchover && "🕐 Switchover scheduled" || "📝 Pending changes saved (not deployed)"}
651
+ ✏️ Edited (only in this browser, until deployed)
602
652
  </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)}
607
- </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)})`}
653
+ <ParametersDiff base={liveParameters} next={config.parameters} />
654
+ <div className={css.hbox(12).wrap}>
655
+ <button
656
+ className={css.pad2(12, 8).button.bord2(0, 0, 20).hsl(120, 70, 85).fontWeight("bold")}
657
+ disabled={this.state.isDeploying}
658
+ onClick={() => {
659
+ this.deployConfig(makeDeployConfig(scheduledTime || Date.now() + overlap));
660
+ }}>
661
+ {this.state.isDeploying && "⏳ Scheduling..." || scheduledDeploy && "🔁 Replace Scheduled Deploy" || "🚀 Schedule Deploy"}
662
+ </button>
663
+ <div className={css.opacity(scheduledTime && 1 || 0.5)}>
664
+ <InputLabel
665
+ label="Switch at"
666
+ isDatetime
667
+ value={effectiveSwitchTime}
668
+ onChangeValue={value => {
669
+ this.state.switchTime = Number(value) || 0;
670
+ }}
671
+ />
610
672
  </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>
673
+ <span>(in {formatTime(effectiveSwitchTime - now)})</span>
674
+ {!!scheduledTime && <button
675
+ className={css.pad2(12, 8).button.bord2(0, 0, 20).hsl(0, 0, 90)}
676
+ onClick={() => {
677
+ this.state.switchTime = 0;
678
+ }}>
679
+ Reset to overlap-based time
680
+ </button>}
681
+ <div className={css.opacity(scheduledTime && 0.5 || 1)}>
682
+ <InputLabel
683
+ label="Overlap (seconds)"
684
+ integer
685
+ value={overlapSeconds}
686
+ onChangeValue={value => {
687
+ this.state.overlapOverride = Number(value) || 0;
688
+ }}
689
+ />
619
690
  </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
- })()}
691
+ </div>
692
+ {!!scheduledTime && scheduledTime <= now && <span className={css.colorhsl(0, 85, 45).fontWeight("bold")}>
693
+ ⚠️ The switch time is in the past deploying switches immediately
694
+ </span>}
695
+ {notEnoughTime && <span className={css.colorhsl(35, 85, 40).fontWeight("bold")}>
696
+ ⚠️ The switch time is sooner than the overlap ({formatTime(overlap)}) — the new instances won't be fully started before the old ones shut down
697
+ </span>}
698
+ <div className={css.hbox(8)}>
699
+ <button
700
+ className={css.pad2(12, 8).button.bord2(0, 70, 40).hsl(0, 70, 90)}
701
+ disabled={this.state.isDeploying}
702
+ onClick={() => {
703
+ this.confirmForceDeployNow(makeDeployConfig(0));
704
+ }}>
705
+ ⚡ Force Deploy Now
706
+ </button>
707
+ </div>
690
708
  </div>;
691
709
  })()}
692
710
 
@@ -700,17 +718,27 @@ export class ServiceDetailPage extends qreact.Component {
700
718
  <TypedConfigEditor
701
719
  value={config}
702
720
  onValueChange={(newValue) => {
703
- this.state.unsavedChanges = newValue as ServiceConfig;
721
+ this.updateEditorState(newValue as ServiceConfig);
704
722
  }}
705
723
  typeDefinition={serviceConfigType}
706
724
  sizeClassName={css.size(1000, 600)}
707
725
  />
726
+ {hasUnsavedChanges && <button
727
+ className={css.pad2(12, 8).button.bord2(0, 0, 20).hsl(50, 80, 50)}
728
+ onClick={() => {
729
+ this.state.editorState = undefined;
730
+ }}>
731
+ Discard Changes
732
+ </button>}
708
733
  </div>;
709
734
  }
710
735
  }
711
736
 
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 }> {
737
+ // 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.
738
+ class ParametersDiff extends qreact.Component<{ base: ServiceParameters; next: ServiceParameters }> {
739
+ state = t.state({
740
+ showRaw: t.type(false),
741
+ });
714
742
  render() {
715
743
  let baseValues = new Map<string, unknown>();
716
744
  let nextValues = new Map<string, unknown>();
@@ -719,22 +747,42 @@ class ConfigDiff extends qreact.Component<{ base: ServiceConfig; next: ServiceCo
719
747
  let keys = Array.from(new Set([...baseValues.keys(), ...nextValues.keys()]));
720
748
  sort(keys, x => x);
721
749
  let changed = keys.filter(key =>
722
- // The save time always changes on save, so it would just be noise
723
- key !== "info.lastUpdatedTime"
750
+ // The release time is displayed separately, so it would just be noise
751
+ key !== "releaseTime"
724
752
  && JSON.stringify(baseValues.get(key)) !== JSON.stringify(nextValues.get(key))
725
753
  );
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>)}
754
+ return <div className={css.vbox(4).fillWidth}>
755
+ {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)}>
756
+ {changed.map(key => <div className={css.hbox(6).wrap}>
757
+ <span className={css.boldStyle}>{key}:</span>
758
+ {baseValues.has(key) && <span className={css.colorhsl(0, 70, 40).textDecoration("line-through")}>{formatDiffValue(baseValues.get(key))}</span>}
759
+ {nextValues.has(key) && <span className={css.colorhsl(120, 60, 30)}>{formatDiffValue(nextValues.get(key))}</span> || <span className={css.colorhsl(0, 70, 40)}>(removed)</span>}
760
+ </div>)}
761
+ </div>}
762
+ <div className={css.hbox(8)}>
763
+ <button
764
+ className={css.pad2(8, 4).button.bord2(0, 0, 30).hsl(0, 0, 92)}
765
+ onClick={() => this.state.showRaw = !this.state.showRaw}>
766
+ {this.state.showRaw && "🧾 Hide Raw Parameters" || "🧾 Show Raw Parameters"}
767
+ </button>
768
+ </div>
769
+ {this.state.showRaw && <div className={css.hbox(24).wrap.alignItems("start")}>
770
+ <div className={css.vbox(2)}>
771
+ <div className={css.boldStyle}>Live</div>
772
+ <div className={css.fontFamily("monospace").fontSize(12).whiteSpace("pre-wrap")}>{JSON.stringify(this.props.base, undefined, 2)}</div>
773
+ </div>
774
+ <div className={css.vbox(2)}>
775
+ <div className={css.boldStyle}>New</div>
776
+ <div className={css.fontFamily("monospace").fontSize(12).whiteSpace("pre-wrap")}>{JSON.stringify(this.props.next, undefined, 2)}</div>
777
+ </div>
778
+ </div>}
735
779
  </div>;
736
780
  }
737
781
  }
782
+ // The save time is set by the server on every save, so it would make every editor value read as changed
783
+ function configCompareKey(config: ServiceConfig): string {
784
+ return JSON.stringify({ ...config, info: { ...config.info, lastUpdatedTime: 0 } });
785
+ }
738
786
  function flattenConfigValues(obj: unknown, prefix: string, out: Map<string, unknown>) {
739
787
  if (obj && typeof obj === "object" && !Array.isArray(obj)) {
740
788
  for (let [key, value] of Object.entries(obj as { [key: string]: unknown })) {