@cronvello/sdk 0.2.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +56 -0
- package/README.md +102 -15
- package/dist/cli.cjs +990 -216
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +990 -216
- package/dist/cli.js.map +1 -1
- package/dist/dev.cjs +1001 -217
- package/dist/dev.cjs.map +1 -1
- package/dist/dev.d.cts +5 -0
- package/dist/dev.d.ts +5 -0
- package/dist/dev.js +1001 -217
- package/dist/dev.js.map +1 -1
- package/dist/{dispatch-handler-BoadpsL9.d.cts → dispatch-handler-yHIFEnNG.d.cts} +17 -5
- package/dist/{dispatch-handler-BoadpsL9.d.ts → dispatch-handler-yHIFEnNG.d.ts} +17 -5
- package/dist/express.d.cts +1 -1
- package/dist/express.d.ts +1 -1
- package/dist/index.cjs +191 -25
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +280 -10
- package/dist/index.d.ts +280 -10
- package/dist/index.js +191 -26
- package/dist/index.js.map +1 -1
- package/dist/next.d.cts +1 -1
- package/dist/next.d.ts +1 -1
- package/docs/dashboard.png +0 -0
- package/package.json +2 -1
package/dist/dev.js
CHANGED
|
@@ -335,6 +335,7 @@ var LocalEngine = class {
|
|
|
335
335
|
started = false;
|
|
336
336
|
stopped = false;
|
|
337
337
|
signalCleanup = null;
|
|
338
|
+
startedAtMs = null;
|
|
338
339
|
constructor(jobs, runner, options = {}) {
|
|
339
340
|
this.clock = options.clock ?? realClock;
|
|
340
341
|
if (options.onEvent) this.listeners.add(options.onEvent);
|
|
@@ -383,7 +384,8 @@ var LocalEngine = class {
|
|
|
383
384
|
if (this.started) return this;
|
|
384
385
|
this.started = true;
|
|
385
386
|
this.stopped = false;
|
|
386
|
-
this.
|
|
387
|
+
this.startedAtMs = this.clock.now();
|
|
388
|
+
this.emit({ type: "engine-start", jobs: this.states.length, at: this.startedAtMs });
|
|
387
389
|
for (const state of this.states) {
|
|
388
390
|
if (state.isReboot) {
|
|
389
391
|
this.launch(state);
|
|
@@ -447,6 +449,14 @@ var LocalEngine = class {
|
|
|
447
449
|
get activeRuns() {
|
|
448
450
|
return this.inFlight.size;
|
|
449
451
|
}
|
|
452
|
+
/** Epoch ms of the `start()` call, or null while the engine has never been started. */
|
|
453
|
+
get startedAt() {
|
|
454
|
+
return this.startedAtMs;
|
|
455
|
+
}
|
|
456
|
+
/** True between `start()` and `stop()`. */
|
|
457
|
+
get running() {
|
|
458
|
+
return this.started && !this.stopped;
|
|
459
|
+
}
|
|
450
460
|
// ── Scheduling ─────────────────────────────────────────────────────────────
|
|
451
461
|
scheduleNext(state) {
|
|
452
462
|
if (this.stopped) return;
|
|
@@ -636,7 +646,7 @@ var DASHBOARD_HTML = `<!doctype html>
|
|
|
636
646
|
<meta charset="utf-8" />
|
|
637
647
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
638
648
|
<meta name="robots" content="noindex" />
|
|
639
|
-
<title>Cronvello \u2014 local
|
|
649
|
+
<title>Cronvello \u2014 local engine</title>
|
|
640
650
|
<style>
|
|
641
651
|
:root {
|
|
642
652
|
--indigo: #4F46E5;
|
|
@@ -645,155 +655,415 @@ var DASHBOARD_HTML = `<!doctype html>
|
|
|
645
655
|
--green: #10B981;
|
|
646
656
|
--amber: #F59E0B;
|
|
647
657
|
--red: #EF4444;
|
|
648
|
-
--bg: #
|
|
658
|
+
--bg: #f7f8fa;
|
|
649
659
|
--panel: #ffffff;
|
|
650
660
|
--panel-2: #f1f5f9;
|
|
651
|
-
--
|
|
661
|
+
--panel-3: #e9edf3;
|
|
662
|
+
--border: #e3e8ef;
|
|
663
|
+
--border-strong: #cbd5e1;
|
|
652
664
|
--text: #0f172a;
|
|
653
665
|
--muted: #64748b;
|
|
654
|
-
--
|
|
666
|
+
--faint: #94a3b8;
|
|
667
|
+
--shadow: 0 1px 2px rgba(15, 23, 42, 0.04), 0 1px 3px rgba(15, 23, 42, 0.06);
|
|
668
|
+
--focus: rgba(79, 70, 229, 0.35);
|
|
655
669
|
}
|
|
656
670
|
[data-theme="dark"] {
|
|
657
|
-
--bg: #
|
|
671
|
+
--bg: #0a0f1c;
|
|
658
672
|
--panel: #111827;
|
|
659
|
-
--panel-2: #
|
|
673
|
+
--panel-2: #161f2f;
|
|
674
|
+
--panel-3: #1c2740;
|
|
660
675
|
--border: #1f2937;
|
|
661
|
-
--
|
|
676
|
+
--border-strong: #334155;
|
|
677
|
+
--text: #e6e9ef;
|
|
662
678
|
--muted: #94a3b8;
|
|
663
|
-
--
|
|
679
|
+
--faint: #64748b;
|
|
680
|
+
--shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 1px 3px rgba(0, 0, 0, 0.35);
|
|
681
|
+
--focus: rgba(99, 102, 241, 0.45);
|
|
664
682
|
}
|
|
665
683
|
/* Dark-mode-first: follow the OS preference unless the user has explicitly chosen light. */
|
|
666
684
|
@media (prefers-color-scheme: dark) {
|
|
667
685
|
:root:not([data-theme="light"]) {
|
|
668
|
-
--bg: #
|
|
686
|
+
--bg: #0a0f1c;
|
|
669
687
|
--panel: #111827;
|
|
670
|
-
--panel-2: #
|
|
688
|
+
--panel-2: #161f2f;
|
|
689
|
+
--panel-3: #1c2740;
|
|
671
690
|
--border: #1f2937;
|
|
672
|
-
--
|
|
691
|
+
--border-strong: #334155;
|
|
692
|
+
--text: #e6e9ef;
|
|
673
693
|
--muted: #94a3b8;
|
|
674
|
-
--
|
|
694
|
+
--faint: #64748b;
|
|
695
|
+
--shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 1px 3px rgba(0, 0, 0, 0.35);
|
|
696
|
+
--focus: rgba(99, 102, 241, 0.45);
|
|
675
697
|
}
|
|
676
698
|
}
|
|
699
|
+
|
|
677
700
|
* { box-sizing: border-box; }
|
|
678
701
|
html, body { margin: 0; padding: 0; }
|
|
679
702
|
body {
|
|
680
703
|
background: var(--bg);
|
|
681
704
|
color: var(--text);
|
|
682
705
|
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Inter, Roboto, sans-serif;
|
|
683
|
-
font-size:
|
|
706
|
+
font-size: 13.5px;
|
|
684
707
|
line-height: 1.5;
|
|
708
|
+
-webkit-font-smoothing: antialiased;
|
|
709
|
+
}
|
|
710
|
+
code, .mono, .num {
|
|
711
|
+
font-family: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace;
|
|
712
|
+
font-variant-numeric: tabular-nums;
|
|
685
713
|
}
|
|
686
|
-
|
|
687
|
-
|
|
714
|
+
.num { font-variant-numeric: tabular-nums; }
|
|
715
|
+
:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; border-radius: 4px; }
|
|
716
|
+
|
|
717
|
+
/* \u2500\u2500 Header \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
|
|
718
|
+
header.top {
|
|
688
719
|
display: flex; align-items: center; gap: 12px;
|
|
689
|
-
padding:
|
|
720
|
+
padding: 0 20px; height: 52px;
|
|
690
721
|
border-bottom: 1px solid var(--border);
|
|
691
722
|
background: var(--panel);
|
|
692
|
-
position: sticky; top: 0; z-index:
|
|
723
|
+
position: sticky; top: 0; z-index: 20;
|
|
693
724
|
}
|
|
694
|
-
.
|
|
695
|
-
width:
|
|
725
|
+
.mark {
|
|
726
|
+
width: 26px; height: 26px; border-radius: 7px; flex: none;
|
|
696
727
|
display: grid; place-items: center;
|
|
697
|
-
background: linear-gradient(
|
|
698
|
-
color: #fff;
|
|
728
|
+
background: linear-gradient(140deg, var(--indigo), var(--teal));
|
|
729
|
+
color: #fff;
|
|
699
730
|
}
|
|
700
|
-
.
|
|
701
|
-
.
|
|
731
|
+
.mark svg { width: 15px; height: 15px; }
|
|
732
|
+
.wordmark { font-weight: 650; letter-spacing: -0.012em; font-size: 14px; }
|
|
733
|
+
.wordmark span { color: var(--muted); font-weight: 450; margin-left: 7px; }
|
|
702
734
|
.spacer { flex: 1; }
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
735
|
+
|
|
736
|
+
.chip {
|
|
737
|
+
display: inline-flex; align-items: center; gap: 7px;
|
|
738
|
+
height: 26px; padding: 0 10px; border-radius: 7px;
|
|
739
|
+
font-size: 12px; font-weight: 500;
|
|
707
740
|
border: 1px solid var(--border); background: var(--panel-2); color: var(--muted);
|
|
741
|
+
white-space: nowrap;
|
|
742
|
+
}
|
|
743
|
+
.chip .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--faint); flex: none; }
|
|
744
|
+
.chip.live .dot { background: var(--green); animation: pulse 2.4s ease-out infinite; }
|
|
745
|
+
.chip.down { color: var(--red); border-color: color-mix(in srgb, var(--red) 40%, var(--border)); }
|
|
746
|
+
.chip.down .dot { background: var(--red); }
|
|
747
|
+
@keyframes pulse {
|
|
748
|
+
0% { box-shadow: 0 0 0 0 rgba(16,185,129,0.45); }
|
|
749
|
+
70% { box-shadow: 0 0 0 5px rgba(16,185,129,0); }
|
|
750
|
+
100% { box-shadow: 0 0 0 0 rgba(16,185,129,0); }
|
|
708
751
|
}
|
|
709
|
-
|
|
710
|
-
.pill.live .dot { background: var(--green); box-shadow: 0 0 0 0 rgba(16,185,129,0.5); animation: pulse 1.8s infinite; }
|
|
711
|
-
.pill.down .dot { background: var(--red); }
|
|
712
|
-
@keyframes pulse { 0% { box-shadow: 0 0 0 0 rgba(16,185,129,0.5); } 70% { box-shadow: 0 0 0 6px rgba(16,185,129,0); } 100% { box-shadow: 0 0 0 0 rgba(16,185,129,0); } }
|
|
752
|
+
|
|
713
753
|
button {
|
|
714
754
|
font: inherit; cursor: pointer; color: var(--text);
|
|
715
755
|
background: var(--panel-2); border: 1px solid var(--border);
|
|
716
|
-
border-radius:
|
|
756
|
+
border-radius: 7px; padding: 5px 11px;
|
|
757
|
+
transition: background-color .12s ease, border-color .12s ease;
|
|
717
758
|
}
|
|
718
|
-
button:hover { border-color: var(--
|
|
759
|
+
button:hover:not(:disabled) { background: var(--panel-3); border-color: var(--border-strong); }
|
|
760
|
+
button:disabled { opacity: .55; cursor: default; }
|
|
719
761
|
button.primary { background: var(--indigo); color: #fff; border-color: var(--indigo); }
|
|
720
|
-
button.primary:hover { background: var(--indigo-soft); }
|
|
721
|
-
.
|
|
722
|
-
|
|
723
|
-
.
|
|
724
|
-
.
|
|
762
|
+
button.primary:hover:not(:disabled) { background: var(--indigo-soft); border-color: var(--indigo-soft); }
|
|
763
|
+
button.icon { padding: 5px 7px; line-height: 0; color: var(--muted); }
|
|
764
|
+
button.icon svg { width: 15px; height: 15px; display: block; }
|
|
765
|
+
button.ghost { background: transparent; border-color: transparent; color: var(--muted); }
|
|
766
|
+
button.ghost:hover:not(:disabled) { background: var(--panel-2); border-color: var(--border); }
|
|
767
|
+
button.tiny { font-size: 11.5px; padding: 3px 8px; border-radius: 6px; }
|
|
768
|
+
|
|
769
|
+
main { padding: 16px 20px 8px; display: flex; flex-direction: column; gap: 14px; }
|
|
770
|
+
|
|
771
|
+
/* \u2500\u2500 Stat row \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
|
|
772
|
+
.stats { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 10px; }
|
|
773
|
+
@media (max-width: 900px) { .stats { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
|
774
|
+
.stat {
|
|
775
|
+
background: var(--panel); border: 1px solid var(--border); border-radius: 10px;
|
|
776
|
+
padding: 11px 13px; box-shadow: var(--shadow); min-width: 0;
|
|
777
|
+
}
|
|
778
|
+
.stat .label {
|
|
779
|
+
font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.06em;
|
|
780
|
+
color: var(--faint); font-weight: 600;
|
|
781
|
+
}
|
|
782
|
+
.stat .value {
|
|
783
|
+
margin-top: 3px; font-size: 21px; font-weight: 600; letter-spacing: -0.02em;
|
|
784
|
+
font-variant-numeric: tabular-nums; line-height: 1.2;
|
|
785
|
+
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
786
|
+
}
|
|
787
|
+
.stat .sub { font-size: 11.5px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
788
|
+
.stat.ok .value { color: var(--green); }
|
|
789
|
+
.stat.bad .value { color: var(--red); }
|
|
790
|
+
.stat.bad.zero .value { color: var(--text); }
|
|
791
|
+
|
|
792
|
+
/* \u2500\u2500 Cards \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
|
|
793
|
+
.card {
|
|
794
|
+
background: var(--panel); border: 1px solid var(--border); border-radius: 11px;
|
|
795
|
+
box-shadow: var(--shadow); overflow: hidden; min-width: 0;
|
|
796
|
+
}
|
|
797
|
+
.card > .head {
|
|
798
|
+
display: flex; align-items: center; gap: 10px;
|
|
799
|
+
padding: 9px 14px; border-bottom: 1px solid var(--border);
|
|
800
|
+
background: var(--panel);
|
|
801
|
+
}
|
|
802
|
+
.card > .head h2 {
|
|
803
|
+
margin: 0; font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em;
|
|
804
|
+
color: var(--muted); font-weight: 650;
|
|
805
|
+
}
|
|
806
|
+
.card > .head .hint { font-size: 11.5px; color: var(--faint); }
|
|
807
|
+
select {
|
|
808
|
+
font: inherit; font-size: 11.5px; color: var(--muted); cursor: pointer;
|
|
809
|
+
background: var(--panel-2); border: 1px solid var(--border); border-radius: 6px; padding: 3px 6px;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
/* \u2500\u2500 Timeline \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
|
|
813
|
+
.tl { display: flex; align-items: stretch; }
|
|
814
|
+
.tl-gutter {
|
|
815
|
+
flex: none; width: 168px; border-right: 1px solid var(--border);
|
|
816
|
+
background: var(--panel); padding-bottom: 8px;
|
|
817
|
+
}
|
|
818
|
+
@media (max-width: 700px) { .tl-gutter { width: 108px; } }
|
|
819
|
+
.tl-gutter .axis-pad { height: 22px; border-bottom: 1px solid var(--border); }
|
|
820
|
+
.tl-gutter .lane-label {
|
|
821
|
+
height: 26px; display: flex; align-items: center; padding: 0 12px;
|
|
822
|
+
font-size: 12px; font-weight: 550;
|
|
823
|
+
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
824
|
+
}
|
|
825
|
+
.tl-viewport { position: relative; flex: 1; overflow: hidden; padding-bottom: 8px; min-width: 0; }
|
|
826
|
+
.tl-track { position: relative; width: 100%; will-change: transform; }
|
|
827
|
+
.tl-axis { position: relative; height: 22px; border-bottom: 1px solid var(--border); }
|
|
828
|
+
.tl-axis .gl {
|
|
829
|
+
position: absolute; top: 0; bottom: -1000px; width: 1px;
|
|
830
|
+
background: var(--border); opacity: .75;
|
|
831
|
+
}
|
|
832
|
+
.tl-axis .gt {
|
|
833
|
+
position: absolute; top: 4px; font-size: 10.5px; color: var(--faint);
|
|
834
|
+
transform: translateX(4px); white-space: nowrap; font-variant-numeric: tabular-nums;
|
|
835
|
+
}
|
|
836
|
+
.tl-lane { position: relative; height: 26px; }
|
|
837
|
+
.tl-lane + .tl-lane { border-top: 1px dashed transparent; }
|
|
838
|
+
.tl-lane:hover { background: color-mix(in srgb, var(--panel-2) 60%, transparent); }
|
|
839
|
+
.mk { position: absolute; top: 7px; height: 12px; border-radius: 2px; }
|
|
840
|
+
.mk.run { width: 4px; margin-left: -2px; }
|
|
841
|
+
.mk.success { background: var(--green); }
|
|
842
|
+
.mk.error, .mk.timed_out { background: var(--red); }
|
|
843
|
+
.mk.skipped { background: var(--faint); }
|
|
844
|
+
.mk.retried { background: var(--amber); }
|
|
845
|
+
.mk.up {
|
|
846
|
+
width: 0; height: 12px; margin-left: -1px;
|
|
847
|
+
border-left: 1px dashed var(--border-strong); border-radius: 0; opacity: .5;
|
|
848
|
+
}
|
|
849
|
+
.mk.up.first { border-left-color: var(--indigo-soft); border-left-style: solid; opacity: 1; }
|
|
850
|
+
.lane-note {
|
|
851
|
+
position: absolute; right: 8px; top: 6px;
|
|
852
|
+
font-size: 10.5px; color: var(--faint); white-space: nowrap;
|
|
853
|
+
font-variant-numeric: tabular-nums; pointer-events: none;
|
|
854
|
+
}
|
|
855
|
+
.tl-now {
|
|
856
|
+
position: absolute; left: 50%; top: 0; bottom: 0; width: 1px;
|
|
857
|
+
background: var(--indigo); opacity: .75; pointer-events: none; z-index: 3;
|
|
858
|
+
}
|
|
859
|
+
.tl-now::before {
|
|
860
|
+
content: ""; position: absolute; top: 0; left: -3px;
|
|
861
|
+
border: 3.5px solid transparent; border-top-color: var(--indigo);
|
|
862
|
+
}
|
|
863
|
+
.tl-empty { padding: 22px 16px; color: var(--muted); text-align: center; font-size: 12.5px; }
|
|
864
|
+
.tl-legend { display: flex; gap: 14px; align-items: center; font-size: 11px; color: var(--faint); }
|
|
865
|
+
.tl-legend i { display: inline-block; width: 8px; height: 8px; border-radius: 2px; margin-right: 5px; vertical-align: -1px; }
|
|
866
|
+
@media (max-width: 700px) { .tl-legend { display: none; } }
|
|
867
|
+
|
|
868
|
+
/* \u2500\u2500 Grid \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
|
|
869
|
+
.grid { display: grid; grid-template-columns: minmax(0, 1.55fr) minmax(0, 1fr); gap: 14px; align-items: start; }
|
|
870
|
+
@media (max-width: 980px) { .grid { grid-template-columns: minmax(0, 1fr); } }
|
|
871
|
+
|
|
872
|
+
/* \u2500\u2500 Jobs table \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
|
|
725
873
|
table { width: 100%; border-collapse: collapse; }
|
|
726
|
-
th, td { text-align: left; padding:
|
|
727
|
-
th {
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
874
|
+
th, td { text-align: left; padding: 9px 14px; border-bottom: 1px solid var(--border); vertical-align: middle; }
|
|
875
|
+
th {
|
|
876
|
+
font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.055em;
|
|
877
|
+
color: var(--faint); font-weight: 650; padding-top: 8px; padding-bottom: 8px;
|
|
878
|
+
}
|
|
879
|
+
tbody tr:last-child td { border-bottom: none; }
|
|
880
|
+
tbody tr.job { cursor: pointer; }
|
|
881
|
+
tbody tr.job:hover { background: var(--panel-2); }
|
|
882
|
+
tbody tr.job.is-running { background: color-mix(in srgb, var(--indigo) 6%, transparent); }
|
|
731
883
|
.jobkey { font-weight: 600; }
|
|
732
|
-
.jobdesc { color: var(--muted); font-size:
|
|
733
|
-
.
|
|
734
|
-
.
|
|
735
|
-
.
|
|
736
|
-
.
|
|
737
|
-
.
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
.
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
.
|
|
884
|
+
.jobdesc { color: var(--muted); font-size: 11.5px; margin-top: 1px; }
|
|
885
|
+
td.sched code { font-size: 12px; color: var(--muted); }
|
|
886
|
+
td.next { white-space: nowrap; }
|
|
887
|
+
td.act { text-align: right; width: 1%; white-space: nowrap; }
|
|
888
|
+
tbody tr.job .act button { opacity: 0; transition: opacity .12s ease; }
|
|
889
|
+
tbody tr.job:hover .act button, tbody tr.job .act button:focus-visible { opacity: 1; }
|
|
890
|
+
@media (hover: none) { tbody tr.job .act button { opacity: 1; } }
|
|
891
|
+
|
|
892
|
+
.badge {
|
|
893
|
+
display: inline-block; padding: 1.5px 8px; border-radius: 999px;
|
|
894
|
+
font-size: 10.5px; font-weight: 650; letter-spacing: 0.01em;
|
|
895
|
+
}
|
|
896
|
+
.badge.success { background: color-mix(in srgb, var(--green) 16%, transparent); color: var(--green); }
|
|
897
|
+
.badge.error, .badge.timed_out { background: color-mix(in srgb, var(--red) 16%, transparent); color: var(--red); }
|
|
898
|
+
.badge.skipped { background: color-mix(in srgb, var(--faint) 20%, transparent); color: var(--muted); }
|
|
899
|
+
.badge.running { background: color-mix(in srgb, var(--indigo) 16%, transparent); color: var(--indigo-soft); }
|
|
900
|
+
.badge.none { background: var(--panel-2); color: var(--faint); }
|
|
901
|
+
|
|
902
|
+
/* \u2500\u2500 Feed \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
|
|
903
|
+
.feed { max-height: 62vh; overflow-y: auto; overscroll-behavior: contain; }
|
|
904
|
+
.ev {
|
|
905
|
+
display: flex; gap: 9px; align-items: baseline;
|
|
906
|
+
padding: 7px 14px; border-bottom: 1px solid var(--border); font-size: 12.5px;
|
|
907
|
+
}
|
|
908
|
+
.ev:last-child { border-bottom: none; }
|
|
909
|
+
.ev .glyph { width: 13px; text-align: center; flex: none; font-size: 11px; }
|
|
910
|
+
.ev .body { flex: 1; min-width: 0; overflow-wrap: anywhere; }
|
|
911
|
+
.ev .when { color: var(--faint); font-size: 10.5px; font-variant-numeric: tabular-nums; flex: none; }
|
|
912
|
+
.ev .k { font-weight: 600; }
|
|
745
913
|
.ev.success .glyph { color: var(--green); }
|
|
746
914
|
.ev.error .glyph, .ev.timeout .glyph { color: var(--red); }
|
|
747
915
|
.ev.retry .glyph { color: var(--amber); }
|
|
748
916
|
.ev.fire .glyph { color: var(--indigo-soft); }
|
|
749
|
-
.ev.skipped .glyph, .ev.
|
|
750
|
-
.
|
|
751
|
-
|
|
752
|
-
|
|
917
|
+
.ev.skipped .glyph, .ev.engine .glyph { color: var(--faint); }
|
|
918
|
+
.ev .detail { color: var(--muted); }
|
|
919
|
+
.paused-note {
|
|
920
|
+
padding: 6px 14px; font-size: 11.5px; color: var(--amber);
|
|
921
|
+
background: color-mix(in srgb, var(--amber) 10%, transparent);
|
|
922
|
+
border-bottom: 1px solid var(--border);
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
.empty { padding: 26px 16px; color: var(--muted); text-align: center; font-size: 12.5px; }
|
|
926
|
+
.empty .lead { color: var(--text); font-weight: 550; margin-bottom: 3px; }
|
|
927
|
+
|
|
928
|
+
footer {
|
|
929
|
+
padding: 14px 20px 30px; color: var(--faint); font-size: 11.5px;
|
|
930
|
+
display: flex; gap: 8px; flex-wrap: wrap; align-items: center;
|
|
931
|
+
}
|
|
932
|
+
footer .sep { opacity: .5; }
|
|
933
|
+
|
|
934
|
+
/* \u2500\u2500 Drawer \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
|
|
935
|
+
.overlay {
|
|
936
|
+
position: fixed; inset: 0; background: rgba(2, 6, 23, 0.5);
|
|
937
|
+
display: none; justify-content: flex-end; z-index: 40;
|
|
938
|
+
}
|
|
753
939
|
.overlay.open { display: flex; }
|
|
754
|
-
.drawer {
|
|
755
|
-
|
|
940
|
+
.drawer {
|
|
941
|
+
width: min(560px, 94vw); background: var(--panel); border-left: 1px solid var(--border);
|
|
942
|
+
height: 100%; overflow-y: auto; display: flex; flex-direction: column;
|
|
943
|
+
}
|
|
944
|
+
.drawer .dhead {
|
|
945
|
+
display: flex; align-items: center; gap: 10px; padding: 12px 16px;
|
|
946
|
+
border-bottom: 1px solid var(--border); position: sticky; top: 0; background: var(--panel); z-index: 2;
|
|
947
|
+
}
|
|
948
|
+
.drawer .dtitle { font-weight: 650; letter-spacing: -0.01em; }
|
|
756
949
|
.drawer .pad { padding: 16px; }
|
|
757
|
-
.kv { display: flex; gap:
|
|
758
|
-
.kv span:first-child { color: var(--muted); min-width:
|
|
759
|
-
.section-title {
|
|
950
|
+
.kv { display: flex; gap: 10px; padding: 5px 0; font-size: 12.5px; }
|
|
951
|
+
.kv > span:first-child { color: var(--muted); min-width: 92px; flex: none; }
|
|
952
|
+
.section-title {
|
|
953
|
+
font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.06em;
|
|
954
|
+
color: var(--faint); font-weight: 650; margin: 20px 0 7px;
|
|
955
|
+
}
|
|
760
956
|
ul.plain { list-style: none; margin: 0; padding: 0; }
|
|
761
|
-
ul.plain li {
|
|
957
|
+
ul.plain > li {
|
|
958
|
+
padding: 7px 0; border-bottom: 1px solid var(--border);
|
|
959
|
+
display: flex; justify-content: space-between; gap: 10px; font-size: 12.5px;
|
|
960
|
+
}
|
|
961
|
+
ul.plain > li:last-child { border-bottom: none; }
|
|
962
|
+
.runline { display: block; }
|
|
963
|
+
.runline .row1 { display: flex; justify-content: space-between; gap: 10px; align-items: baseline; }
|
|
964
|
+
.runline .meta { color: var(--faint); font-size: 11px; font-variant-numeric: tabular-nums; }
|
|
965
|
+
.runline pre {
|
|
966
|
+
margin: 6px 0 0; padding: 8px 10px; border-radius: 7px;
|
|
967
|
+
background: var(--panel-2); border: 1px solid var(--border);
|
|
968
|
+
font-size: 11.5px; white-space: pre-wrap; overflow-wrap: anywhere; max-height: 180px; overflow: auto;
|
|
969
|
+
}
|
|
970
|
+
.runline pre.err { color: var(--red); border-color: color-mix(in srgb, var(--red) 30%, var(--border)); }
|
|
762
971
|
</style>
|
|
763
972
|
</head>
|
|
764
973
|
<body>
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
<div class="
|
|
974
|
+
|
|
975
|
+
<header class="top">
|
|
976
|
+
<div class="mark" aria-hidden="true">
|
|
977
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"></circle><path d="M12 7.2V12l3.4 2"></path></svg>
|
|
978
|
+
</div>
|
|
979
|
+
<div class="wordmark">Cronvello <span>local engine</span></div>
|
|
980
|
+
<span id="engine-chip" class="chip"><span class="dot"></span><span id="engine-text">starting…</span></span>
|
|
768
981
|
<div class="spacer"></div>
|
|
769
|
-
<span id="conn" class="
|
|
770
|
-
<button id="theme-btn" title="Toggle theme"
|
|
982
|
+
<span id="conn" class="chip"><span class="dot"></span><span id="conn-text">connecting…</span></span>
|
|
983
|
+
<button id="theme-btn" class="icon" title="Toggle light / dark" aria-label="Toggle light or dark theme">
|
|
984
|
+
<svg id="icon-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 14.5A8.5 8.5 0 0 1 9.5 4a8.5 8.5 0 1 0 10.5 10.5Z"></path></svg>
|
|
985
|
+
<svg id="icon-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" hidden><circle cx="12" cy="12" r="4"></circle><path d="M12 2.5v2M12 19.5v2M2.5 12h2M19.5 12h2M5.2 5.2l1.4 1.4M17.4 17.4l1.4 1.4M18.8 5.2l-1.4 1.4M6.6 17.4l-1.4 1.4"></path></svg>
|
|
986
|
+
</button>
|
|
771
987
|
</header>
|
|
772
988
|
|
|
773
|
-
<
|
|
774
|
-
<section class="
|
|
775
|
-
<
|
|
776
|
-
<
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
</
|
|
989
|
+
<main>
|
|
990
|
+
<section class="stats" aria-label="Engine summary">
|
|
991
|
+
<div class="stat"><div class="label">Jobs</div><div class="value num" id="s-jobs">—</div><div class="sub" id="s-jobs-sub"> </div></div>
|
|
992
|
+
<div class="stat"><div class="label">Runs this session</div><div class="value num" id="s-runs">—</div><div class="sub" id="s-runs-sub"> </div></div>
|
|
993
|
+
<div class="stat ok"><div class="label">Succeeded</div><div class="value num" id="s-ok">—</div><div class="sub" id="s-ok-sub"> </div></div>
|
|
994
|
+
<div class="stat bad zero"><div class="label">Failed</div><div class="value num" id="s-bad">—</div><div class="sub" id="s-bad-sub"> </div></div>
|
|
995
|
+
<div class="stat"><div class="label">Next fire</div><div class="value num" id="s-next">—</div><div class="sub" id="s-next-sub"> </div></div>
|
|
780
996
|
</section>
|
|
781
997
|
|
|
782
998
|
<section class="card">
|
|
783
|
-
<
|
|
784
|
-
|
|
999
|
+
<div class="head">
|
|
1000
|
+
<h2>Timeline</h2>
|
|
1001
|
+
<span class="hint" id="tl-hint"></span>
|
|
1002
|
+
<div class="spacer"></div>
|
|
1003
|
+
<div class="tl-legend" aria-hidden="true">
|
|
1004
|
+
<span><i style="background:var(--green)"></i>ok</span>
|
|
1005
|
+
<span><i style="background:var(--red)"></i>failed</span>
|
|
1006
|
+
<span><i style="background:var(--faint)"></i>skipped</span>
|
|
1007
|
+
<span><i style="border-left:2px dashed var(--border-strong);width:2px;border-radius:0"></i>upcoming</span>
|
|
1008
|
+
</div>
|
|
1009
|
+
<label class="hint" for="tl-span">Window</label>
|
|
1010
|
+
<select id="tl-span" aria-label="Timeline window">
|
|
1011
|
+
<option value="120000">2 min</option>
|
|
1012
|
+
<option value="600000" selected>10 min</option>
|
|
1013
|
+
<option value="3600000">1 hour</option>
|
|
1014
|
+
<option value="21600000">6 hours</option>
|
|
1015
|
+
</select>
|
|
1016
|
+
</div>
|
|
1017
|
+
<div class="tl" id="tl">
|
|
1018
|
+
<div class="tl-gutter" id="tl-gutter"><div class="axis-pad"></div></div>
|
|
1019
|
+
<div class="tl-viewport" id="tl-viewport">
|
|
1020
|
+
<div class="tl-track" id="tl-track"><div class="tl-axis" id="tl-axis"></div></div>
|
|
1021
|
+
<div class="tl-now" aria-hidden="true"></div>
|
|
1022
|
+
</div>
|
|
1023
|
+
</div>
|
|
1024
|
+
<div class="tl-empty" id="tl-empty" hidden>No jobs to plot.</div>
|
|
785
1025
|
</section>
|
|
786
|
-
</div>
|
|
787
1026
|
|
|
788
|
-
<
|
|
1027
|
+
<div class="grid">
|
|
1028
|
+
<section class="card">
|
|
1029
|
+
<div class="head"><h2>Jobs</h2><span class="hint" id="jobs-hint"></span></div>
|
|
1030
|
+
<table>
|
|
1031
|
+
<thead><tr><th>Job</th><th>Schedule</th><th>Zone</th><th>Next fire</th><th>Last run</th><th></th></tr></thead>
|
|
1032
|
+
<tbody id="jobs"><tr><td colspan="6" class="empty">Loading…</td></tr></tbody>
|
|
1033
|
+
</table>
|
|
1034
|
+
</section>
|
|
789
1035
|
|
|
790
|
-
<
|
|
1036
|
+
<section class="card">
|
|
1037
|
+
<div class="head">
|
|
1038
|
+
<h2>Live feed</h2>
|
|
1039
|
+
<div class="spacer"></div>
|
|
1040
|
+
<button id="feed-pause" class="tiny ghost" aria-pressed="false">Pause</button>
|
|
1041
|
+
<button id="feed-clear" class="tiny ghost">Clear</button>
|
|
1042
|
+
</div>
|
|
1043
|
+
<div id="feed-paused" class="paused-note" hidden></div>
|
|
1044
|
+
<div id="feed" class="feed" role="log" aria-label="Live run feed">
|
|
1045
|
+
<div class="empty"><div class="lead">Waiting for the first run.</div><div id="feed-hint">Every fire shows up here as it happens.</div></div>
|
|
1046
|
+
</div>
|
|
1047
|
+
</section>
|
|
1048
|
+
</div>
|
|
1049
|
+
</main>
|
|
1050
|
+
|
|
1051
|
+
<footer>
|
|
1052
|
+
<span>Local engine</span><span class="sep">·</span>
|
|
1053
|
+
<span>no account</span><span class="sep">·</span>
|
|
1054
|
+
<span>no cloud</span><span class="sep">·</span>
|
|
1055
|
+
<span>no outbound network</span><span class="sep">·</span>
|
|
1056
|
+
<span>this process is the only source of truth</span>
|
|
1057
|
+
</footer>
|
|
1058
|
+
|
|
1059
|
+
<div id="overlay" class="overlay" role="dialog" aria-modal="true" aria-labelledby="drawer-title" hidden>
|
|
791
1060
|
<div class="drawer">
|
|
792
|
-
<
|
|
793
|
-
<div class="
|
|
1061
|
+
<div class="dhead">
|
|
1062
|
+
<div class="dtitle" id="drawer-title">Job</div>
|
|
794
1063
|
<div class="spacer"></div>
|
|
795
|
-
<button id="drawer-
|
|
796
|
-
|
|
1064
|
+
<button id="drawer-run" class="primary tiny">Run now</button>
|
|
1065
|
+
<button id="drawer-close" class="tiny">Close</button>
|
|
1066
|
+
</div>
|
|
797
1067
|
<div class="pad" id="drawer-body"></div>
|
|
798
1068
|
</div>
|
|
799
1069
|
</div>
|
|
@@ -801,40 +1071,64 @@ var DASHBOARD_HTML = `<!doctype html>
|
|
|
801
1071
|
<script>
|
|
802
1072
|
(function () {
|
|
803
1073
|
"use strict";
|
|
804
|
-
|
|
1074
|
+
|
|
1075
|
+
var MAX_FEED = 250;
|
|
1076
|
+
var REFRESH_MS = 10000; // reconciliation poll behind the event stream
|
|
1077
|
+
var DEBOUNCE_MS = 400; // collapse a burst of events into one refetch
|
|
1078
|
+
var UPCOMING = 40; // fire times requested per job
|
|
1079
|
+
|
|
1080
|
+
var state = { engine: {}, jobs: [], runs: [] };
|
|
805
1081
|
var jobsByKey = {};
|
|
1082
|
+
var rows = {}; // key -> { tr, next, last, running }
|
|
1083
|
+
var lanes = {}; // key -> { lane, label }
|
|
1084
|
+
var laneOrder = [];
|
|
1085
|
+
var anchorNow = Date.now();
|
|
1086
|
+
var spanMs = 600000;
|
|
1087
|
+
var openKey = null;
|
|
1088
|
+
var lastFocus = null;
|
|
1089
|
+
var paused = false;
|
|
1090
|
+
var pausedCount = 0;
|
|
1091
|
+
var refreshTimer = null;
|
|
806
1092
|
|
|
807
1093
|
function el(id) { return document.getElementById(id); }
|
|
808
|
-
function
|
|
809
|
-
return String(s == null ? "" : s).replace(/[&<>"']/g, function (c) {
|
|
810
|
-
return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c];
|
|
811
|
-
});
|
|
812
|
-
}
|
|
813
|
-
function pad(n) { return n < 10 ? "0" + n : "" + n; }
|
|
1094
|
+
function pad2(n) { return n < 10 ? "0" + n : "" + n; }
|
|
814
1095
|
function clockTime(ms) {
|
|
815
1096
|
var d = new Date(ms);
|
|
816
|
-
return
|
|
1097
|
+
return pad2(d.getHours()) + ":" + pad2(d.getMinutes()) + ":" + pad2(d.getSeconds());
|
|
817
1098
|
}
|
|
818
1099
|
function fmtDuration(ms) {
|
|
819
1100
|
if (ms == null) return "";
|
|
820
1101
|
if (ms < 1000) return ms + "ms";
|
|
821
|
-
return (ms / 1000).toFixed(ms < 10000 ? 2 : 1) + "s";
|
|
1102
|
+
if (ms < 60000) return (ms / 1000).toFixed(ms < 10000 ? 2 : 1) + "s";
|
|
1103
|
+
var m = Math.floor(ms / 60000);
|
|
1104
|
+
return m + "m " + Math.round((ms % 60000) / 1000) + "s";
|
|
822
1105
|
}
|
|
823
|
-
function
|
|
824
|
-
|
|
825
|
-
var diff = new Date(iso).getTime() - Date.now();
|
|
826
|
-
if (diff <= 0) return "due now";
|
|
827
|
-
var s = Math.floor(diff / 1000);
|
|
1106
|
+
function fmtElapsed(ms) {
|
|
1107
|
+
var s = Math.max(0, Math.floor(ms / 1000));
|
|
828
1108
|
var d = Math.floor(s / 86400); s -= d * 86400;
|
|
829
1109
|
var h = Math.floor(s / 3600); s -= h * 3600;
|
|
830
1110
|
var m = Math.floor(s / 60); s -= m * 60;
|
|
831
|
-
|
|
832
|
-
if (
|
|
833
|
-
if (
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
1111
|
+
if (d) return d + "d " + h + "h";
|
|
1112
|
+
if (h) return h + "h " + m + "m";
|
|
1113
|
+
if (m) return m + "m " + s + "s";
|
|
1114
|
+
return s + "s";
|
|
1115
|
+
}
|
|
1116
|
+
/** A round duration, for labels rather than countdowns: "5m", not "5m 0s". */
|
|
1117
|
+
function fmtSpan(ms) {
|
|
1118
|
+
if (ms % 3600000 === 0) return (ms / 3600000) + "h";
|
|
1119
|
+
if (ms % 60000 === 0) return (ms / 60000) + "m";
|
|
1120
|
+
return Math.round(ms / 1000) + "s";
|
|
837
1121
|
}
|
|
1122
|
+
function countdown(iso) {
|
|
1123
|
+
if (!iso) return "\\u2014";
|
|
1124
|
+
var diff = new Date(iso).getTime() - Date.now();
|
|
1125
|
+
if (diff <= 0) return "due now";
|
|
1126
|
+
return "in " + fmtElapsed(diff);
|
|
1127
|
+
}
|
|
1128
|
+
function localTime(iso) {
|
|
1129
|
+
try { return new Date(iso).toLocaleString(); } catch (e) { return String(iso); }
|
|
1130
|
+
}
|
|
1131
|
+
function setText(node, text) { if (node && node.textContent !== text) node.textContent = text; }
|
|
838
1132
|
|
|
839
1133
|
// \u2500\u2500 Theme \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
840
1134
|
function applyTheme(t) {
|
|
@@ -846,174 +1140,626 @@ var DASHBOARD_HTML = `<!doctype html>
|
|
|
846
1140
|
if (t) return t;
|
|
847
1141
|
return window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
|
848
1142
|
}
|
|
1143
|
+
// The icon offers the theme you would switch *to*, which is the only reading that stays true
|
|
1144
|
+
// whether the current theme came from the OS or from a previous click.
|
|
1145
|
+
function paintThemeIcon() {
|
|
1146
|
+
var dark = resolvedTheme() === "dark";
|
|
1147
|
+
el("icon-sun").hidden = !dark;
|
|
1148
|
+
el("icon-moon").hidden = dark;
|
|
1149
|
+
el("theme-btn").title = dark ? "Switch to light" : "Switch to dark";
|
|
1150
|
+
}
|
|
849
1151
|
try { applyTheme(localStorage.getItem("cronvello-theme")); } catch (e) {}
|
|
1152
|
+
paintThemeIcon();
|
|
850
1153
|
el("theme-btn").addEventListener("click", function () {
|
|
851
1154
|
var next = resolvedTheme() === "dark" ? "light" : "dark";
|
|
852
1155
|
applyTheme(next);
|
|
1156
|
+
paintThemeIcon();
|
|
853
1157
|
try { localStorage.setItem("cronvello-theme", next); } catch (e) {}
|
|
854
1158
|
});
|
|
855
1159
|
|
|
1160
|
+
// \u2500\u2500 Badges \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
1161
|
+
function badge(status) {
|
|
1162
|
+
var span = document.createElement("span");
|
|
1163
|
+
span.className = "badge " + (status || "none");
|
|
1164
|
+
span.textContent = status || "\\u2014";
|
|
1165
|
+
return span;
|
|
1166
|
+
}
|
|
1167
|
+
function replaceChild1(cell, node) {
|
|
1168
|
+
cell.textContent = "";
|
|
1169
|
+
cell.appendChild(node);
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
// \u2500\u2500 Stats \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
1173
|
+
function renderStats() {
|
|
1174
|
+
var jobs = state.jobs, runs = state.runs;
|
|
1175
|
+
var ok = 0, bad = 0, skipped = 0, retried = 0;
|
|
1176
|
+
for (var i = 0; i < runs.length; i++) {
|
|
1177
|
+
var s = runs[i].status;
|
|
1178
|
+
if (s === "success") { ok++; if (runs[i].attempts > 1) retried++; }
|
|
1179
|
+
else if (s === "skipped") skipped++;
|
|
1180
|
+
else bad++;
|
|
1181
|
+
}
|
|
1182
|
+
var active = 0;
|
|
1183
|
+
for (var j = 0; j < jobs.length; j++) if (jobs[j].running) active++;
|
|
1184
|
+
|
|
1185
|
+
setText(el("s-jobs"), String(jobs.length));
|
|
1186
|
+
setText(el("s-jobs-sub"), active ? active + " running now" : "idle");
|
|
1187
|
+
|
|
1188
|
+
setText(el("s-runs"), String(runs.length));
|
|
1189
|
+
var upSince = state.engine.startedAt ? "up " + fmtElapsed(Date.now() - state.engine.startedAt) : "not started";
|
|
1190
|
+
setText(el("s-runs-sub"), upSince);
|
|
1191
|
+
|
|
1192
|
+
setText(el("s-ok"), String(ok));
|
|
1193
|
+
setText(el("s-ok-sub"), retried ? retried + " after a retry" : (skipped ? skipped + " skipped (overlap)" : "\\u00a0"));
|
|
1194
|
+
|
|
1195
|
+
setText(el("s-bad"), String(bad));
|
|
1196
|
+
el("s-bad").parentNode.className = "stat bad" + (bad ? "" : " zero");
|
|
1197
|
+
setText(el("s-bad-sub"), bad ? "check the feed" : "nothing failed");
|
|
1198
|
+
|
|
1199
|
+
// Soonest upcoming fire across all jobs.
|
|
1200
|
+
var soonest = null, soonestKey = "";
|
|
1201
|
+
for (var k = 0; k < jobs.length; k++) {
|
|
1202
|
+
if (!jobs[k].nextFire) continue;
|
|
1203
|
+
var t = new Date(jobs[k].nextFire).getTime();
|
|
1204
|
+
if (soonest === null || t < soonest) { soonest = t; soonestKey = jobs[k].key; }
|
|
1205
|
+
}
|
|
1206
|
+
if (soonest === null) {
|
|
1207
|
+
setText(el("s-next"), "\\u2014");
|
|
1208
|
+
setText(el("s-next-sub"), jobs.length ? "nothing scheduled" : "\\u00a0");
|
|
1209
|
+
} else {
|
|
1210
|
+
setText(el("s-next"), countdown(new Date(soonest).toISOString()).replace(/^in /, ""));
|
|
1211
|
+
setText(el("s-next-sub"), soonestKey);
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
|
|
856
1215
|
// \u2500\u2500 Jobs table \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
function
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
1216
|
+
// "No jobs" and "not loaded yet" both leave the row map empty, so this flag is what separates
|
|
1217
|
+
// them. Without it the first snapshot of an empty registry would sit on "Loading" forever.
|
|
1218
|
+
var built = false;
|
|
1219
|
+
|
|
1220
|
+
function sameKeys(jobs) {
|
|
1221
|
+
if (!built) return false;
|
|
1222
|
+
var keys = Object.keys(rows);
|
|
1223
|
+
if (keys.length !== jobs.length) return false;
|
|
1224
|
+
for (var i = 0; i < jobs.length; i++) if (!rows[jobs[i].key]) return false;
|
|
1225
|
+
return true;
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
function buildRows(jobs) {
|
|
1229
|
+
var tbody = el("jobs");
|
|
1230
|
+
tbody.textContent = "";
|
|
1231
|
+
rows = {};
|
|
1232
|
+
if (!jobs.length) {
|
|
1233
|
+
var tr0 = document.createElement("tr");
|
|
1234
|
+
var td0 = document.createElement("td");
|
|
1235
|
+
td0.colSpan = 6; td0.className = "empty";
|
|
1236
|
+
td0.innerHTML = '<div class="lead">No jobs registered.</div>Add one to <code>jobs</code> in your config and restart.';
|
|
1237
|
+
tr0.appendChild(td0); tbody.appendChild(tr0);
|
|
1238
|
+
return;
|
|
1239
|
+
}
|
|
865
1240
|
for (var i = 0; i < jobs.length; i++) {
|
|
866
1241
|
var j = jobs[i];
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
1242
|
+
var tr = document.createElement("tr");
|
|
1243
|
+
tr.className = "job";
|
|
1244
|
+
tr.tabIndex = 0;
|
|
1245
|
+
tr.setAttribute("role", "button");
|
|
1246
|
+
tr.setAttribute("aria-label", "Details for " + j.key);
|
|
1247
|
+
tr.dataset.key = j.key;
|
|
1248
|
+
|
|
1249
|
+
var tdName = document.createElement("td");
|
|
1250
|
+
var name = document.createElement("div");
|
|
1251
|
+
name.className = "jobkey"; name.textContent = j.key;
|
|
1252
|
+
tdName.appendChild(name);
|
|
1253
|
+
if (j.description) {
|
|
1254
|
+
var desc = document.createElement("div");
|
|
1255
|
+
desc.className = "jobdesc"; desc.textContent = j.description;
|
|
1256
|
+
tdName.appendChild(desc);
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
var tdSched = document.createElement("td");
|
|
1260
|
+
tdSched.className = "sched";
|
|
1261
|
+
var code = document.createElement("code"); code.textContent = j.schedule;
|
|
1262
|
+
tdSched.appendChild(code);
|
|
1263
|
+
|
|
1264
|
+
var tdZone = document.createElement("td");
|
|
1265
|
+
tdZone.style.color = "var(--muted)";
|
|
1266
|
+
tdZone.textContent = j.timeZone;
|
|
1267
|
+
|
|
1268
|
+
var tdNext = document.createElement("td");
|
|
1269
|
+
tdNext.className = "next num";
|
|
1270
|
+
|
|
1271
|
+
var tdLast = document.createElement("td");
|
|
1272
|
+
|
|
1273
|
+
var tdAct = document.createElement("td");
|
|
1274
|
+
tdAct.className = "act";
|
|
1275
|
+
var btn = document.createElement("button");
|
|
1276
|
+
btn.className = "tiny"; btn.textContent = "Run";
|
|
1277
|
+
btn.setAttribute("aria-label", "Run " + j.key + " now");
|
|
1278
|
+
btn.dataset.key = j.key;
|
|
1279
|
+
tdAct.appendChild(btn);
|
|
1280
|
+
|
|
1281
|
+
tr.appendChild(tdName); tr.appendChild(tdSched); tr.appendChild(tdZone);
|
|
1282
|
+
tr.appendChild(tdNext); tr.appendChild(tdLast); tr.appendChild(tdAct);
|
|
1283
|
+
tbody.appendChild(tr);
|
|
1284
|
+
|
|
1285
|
+
rows[j.key] = { tr: tr, next: tdNext, last: tdLast, btn: btn };
|
|
877
1286
|
}
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
function paintRows(jobs) {
|
|
1290
|
+
for (var i = 0; i < jobs.length; i++) {
|
|
1291
|
+
var j = jobs[i], r = rows[j.key];
|
|
1292
|
+
if (!r) continue;
|
|
1293
|
+
r.tr.classList.toggle("is-running", !!j.running);
|
|
1294
|
+
r.next.dataset.next = j.nextFire || "";
|
|
1295
|
+
setText(r.next, j.nextFire ? countdown(j.nextFire) : "\\u2014");
|
|
1296
|
+
var status = j.running ? "running" : (j.lastRun ? j.lastRun.status : "");
|
|
1297
|
+
if (r.lastStatus !== status) {
|
|
1298
|
+
r.lastStatus = status;
|
|
1299
|
+
replaceChild1(r.last, badge(status));
|
|
1300
|
+
}
|
|
882
1301
|
}
|
|
883
1302
|
}
|
|
1303
|
+
|
|
884
1304
|
function tickCountdowns() {
|
|
885
|
-
var
|
|
886
|
-
|
|
887
|
-
var
|
|
888
|
-
|
|
1305
|
+
for (var key in rows) {
|
|
1306
|
+
if (!Object.prototype.hasOwnProperty.call(rows, key)) continue;
|
|
1307
|
+
var cell = rows[key].next;
|
|
1308
|
+
var iso = cell.dataset.next;
|
|
1309
|
+
setText(cell, iso ? countdown(iso) : "\\u2014");
|
|
1310
|
+
}
|
|
1311
|
+
if (state.engine.startedAt) {
|
|
1312
|
+
setText(el("s-runs-sub"), "up " + fmtElapsed(Date.now() - state.engine.startedAt));
|
|
1313
|
+
}
|
|
1314
|
+
var jobs = state.jobs, soonest = null, soonestKey = "";
|
|
1315
|
+
for (var i = 0; i < jobs.length; i++) {
|
|
1316
|
+
if (!jobs[i].nextFire) continue;
|
|
1317
|
+
var t = new Date(jobs[i].nextFire).getTime();
|
|
1318
|
+
if (soonest === null || t < soonest) { soonest = t; soonestKey = jobs[i].key; }
|
|
1319
|
+
}
|
|
1320
|
+
if (soonest !== null) {
|
|
1321
|
+
setText(el("s-next"), countdown(new Date(soonest).toISOString()).replace(/^in /, ""));
|
|
1322
|
+
setText(el("s-next-sub"), soonestKey);
|
|
889
1323
|
}
|
|
890
1324
|
}
|
|
891
1325
|
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
1326
|
+
// \u2500\u2500 Timeline \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
1327
|
+
//
|
|
1328
|
+
// The track covers a fixed absolute window [anchorNow - span/2, anchorNow + span/2] and every mark
|
|
1329
|
+
// is positioned once, as a percentage of that window. The passing second only shifts the whole
|
|
1330
|
+
// track, so an idle dashboard writes one transform per frame-ish instead of touching every mark.
|
|
1331
|
+
function buildLanes(jobs) {
|
|
1332
|
+
var gutter = el("tl-gutter"), track = el("tl-track");
|
|
1333
|
+
gutter.textContent = "";
|
|
1334
|
+
var pad = document.createElement("div"); pad.className = "axis-pad";
|
|
1335
|
+
gutter.appendChild(pad);
|
|
1336
|
+
|
|
1337
|
+
// Keep the axis, drop the lanes.
|
|
1338
|
+
var axis = el("tl-axis");
|
|
1339
|
+
track.textContent = "";
|
|
1340
|
+
track.appendChild(axis);
|
|
1341
|
+
|
|
1342
|
+
lanes = {}; laneOrder = [];
|
|
1343
|
+
for (var i = 0; i < jobs.length; i++) {
|
|
1344
|
+
var key = jobs[i].key;
|
|
1345
|
+
var label = document.createElement("div");
|
|
1346
|
+
label.className = "lane-label"; label.textContent = key; label.title = key;
|
|
1347
|
+
gutter.appendChild(label);
|
|
1348
|
+
|
|
1349
|
+
var lane = document.createElement("div");
|
|
1350
|
+
lane.className = "tl-lane"; lane.dataset.key = key;
|
|
1351
|
+
track.appendChild(lane);
|
|
1352
|
+
|
|
1353
|
+
lanes[key] = { lane: lane, label: label };
|
|
1354
|
+
laneOrder.push(key);
|
|
1355
|
+
}
|
|
1356
|
+
el("tl").hidden = jobs.length === 0;
|
|
1357
|
+
el("tl-empty").hidden = jobs.length !== 0;
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
function axisStep(span) {
|
|
1361
|
+
var steps = [1000, 5000, 15000, 30000, 60000, 300000, 900000, 1800000, 3600000, 10800000, 21600000];
|
|
1362
|
+
for (var i = 0; i < steps.length; i++) if (span / steps[i] <= 10) return steps[i];
|
|
1363
|
+
return steps[steps.length - 1];
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
function paintTimeline() {
|
|
1367
|
+
var jobs = state.jobs;
|
|
1368
|
+
anchorNow = Date.now();
|
|
1369
|
+
var start = anchorNow - spanMs / 2;
|
|
1370
|
+
var pct = function (t) { return ((t - start) / spanMs) * 100; };
|
|
1371
|
+
el("tl-track").style.transform = "translateX(0%)";
|
|
1372
|
+
|
|
1373
|
+
// Axis gridlines.
|
|
1374
|
+
var axis = el("tl-axis");
|
|
1375
|
+
axis.textContent = "";
|
|
1376
|
+
var step = axisStep(spanMs);
|
|
1377
|
+
var first = Math.ceil(start / step) * step;
|
|
1378
|
+
for (var t = first; t < start + spanMs; t += step) {
|
|
1379
|
+
var p = pct(t);
|
|
1380
|
+
var gl = document.createElement("div");
|
|
1381
|
+
gl.className = "gl"; gl.style.left = p + "%";
|
|
1382
|
+
axis.appendChild(gl);
|
|
1383
|
+
var gt = document.createElement("div");
|
|
1384
|
+
gt.className = "gt"; gt.style.left = p + "%"; gt.textContent = clockTime(t);
|
|
1385
|
+
axis.appendChild(gt);
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
// Marks per lane: past runs, then upcoming fires.
|
|
1389
|
+
var runsByKey = {};
|
|
1390
|
+
for (var i = 0; i < state.runs.length; i++) {
|
|
1391
|
+
var run = state.runs[i];
|
|
1392
|
+
(runsByKey[run.key] || (runsByKey[run.key] = [])).push(run);
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
for (var l = 0; l < laneOrder.length; l++) {
|
|
1396
|
+
var key = laneOrder[l];
|
|
1397
|
+
var lane = lanes[key].lane;
|
|
1398
|
+
lane.textContent = "";
|
|
1399
|
+
|
|
1400
|
+
var list = runsByKey[key] || [];
|
|
1401
|
+
for (var r = 0; r < list.length; r++) {
|
|
1402
|
+
var rec = list[r];
|
|
1403
|
+
if (rec.startedAt < start || rec.startedAt > start + spanMs) continue;
|
|
1404
|
+
var mk = document.createElement("div");
|
|
1405
|
+
mk.className = "mk run " + rec.status + (rec.status === "success" && rec.attempts > 1 ? " retried" : "");
|
|
1406
|
+
mk.style.left = pct(rec.startedAt) + "%";
|
|
1407
|
+
mk.title = key + " \\u00b7 " + rec.status + " \\u00b7 " + clockTime(rec.startedAt) +
|
|
1408
|
+
(rec.durationMs ? " \\u00b7 " + fmtDuration(rec.durationMs) : "") +
|
|
1409
|
+
(rec.attempts > 1 ? " \\u00b7 " + rec.attempts + " attempts" : "");
|
|
1410
|
+
lane.appendChild(mk);
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
var ups = jobsByKey[key] && jobsByKey[key].upcoming ? jobsByKey[key].upcoming : [];
|
|
1414
|
+
var plotted = 0;
|
|
1415
|
+
for (var u = 0; u < ups.length; u++) {
|
|
1416
|
+
var at = new Date(ups[u]).getTime();
|
|
1417
|
+
if (at > start + spanMs) break;
|
|
1418
|
+
if (at < anchorNow) continue;
|
|
1419
|
+
var um = document.createElement("div");
|
|
1420
|
+
um.className = "mk up" + (plotted === 0 ? " first" : "");
|
|
1421
|
+
um.style.left = pct(at) + "%";
|
|
1422
|
+
um.title = key + " \\u00b7 next fire " + clockTime(at);
|
|
1423
|
+
lane.appendChild(um);
|
|
1424
|
+
plotted++;
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
// A lane whose whole schedule sits outside the window would otherwise read as "nothing here",
|
|
1428
|
+
// which is indistinguishable from a broken job. Say when it actually fires.
|
|
1429
|
+
if (!lane.childNodes.length) {
|
|
1430
|
+
var note = document.createElement("div");
|
|
1431
|
+
note.className = "lane-note";
|
|
1432
|
+
var job = jobsByKey[key];
|
|
1433
|
+
note.textContent = job && job.nextFire ? "next " + countdown(job.nextFire) : "not scheduled";
|
|
1434
|
+
lane.appendChild(note);
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
el("tl-hint").textContent = fmtSpan(spanMs / 2) + " back, " + fmtSpan(spanMs / 2) + " ahead";
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
function slideTimeline() {
|
|
1442
|
+
var drift = Date.now() - anchorNow;
|
|
1443
|
+
// Re-anchor rather than sliding forever, so marks that left the window get dropped.
|
|
1444
|
+
if (Math.abs(drift) > spanMs / 4) { paintTimeline(); return; }
|
|
1445
|
+
el("tl-track").style.transform = "translateX(" + (-(drift / spanMs) * 100) + "%)";
|
|
905
1446
|
}
|
|
906
1447
|
|
|
1448
|
+
el("tl-span").addEventListener("change", function () {
|
|
1449
|
+
spanMs = Number(this.value) || 600000;
|
|
1450
|
+
paintTimeline();
|
|
1451
|
+
});
|
|
1452
|
+
|
|
907
1453
|
// \u2500\u2500 Live feed \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
908
|
-
var glyphs = {
|
|
1454
|
+
var glyphs = {
|
|
1455
|
+
fire: "\\u25b8", success: "\\u2713", error: "\\u2717", timeout: "\\u29d6",
|
|
1456
|
+
retry: "\\u21ba", skipped: "\\u2298", scheduled: "\\u00b7",
|
|
1457
|
+
"engine-start": "\\u25cf", "engine-stop": "\\u25a0"
|
|
1458
|
+
};
|
|
1459
|
+
|
|
909
1460
|
function describe(ev) {
|
|
1461
|
+
var frag = document.createDocumentFragment();
|
|
1462
|
+
function k(text) { var s = document.createElement("span"); s.className = "k"; s.textContent = text; return s; }
|
|
1463
|
+
function plain(text) { return document.createTextNode(text); }
|
|
1464
|
+
function dim(text) { var s = document.createElement("span"); s.className = "detail"; s.textContent = text; return s; }
|
|
1465
|
+
|
|
910
1466
|
switch (ev.type) {
|
|
911
|
-
case "fire":
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
case "
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
case "
|
|
920
|
-
|
|
1467
|
+
case "fire":
|
|
1468
|
+
frag.appendChild(k(ev.key)); frag.appendChild(plain(" fired"));
|
|
1469
|
+
if (ev.attempt > 1) frag.appendChild(dim(" (attempt " + ev.attempt + ")"));
|
|
1470
|
+
break;
|
|
1471
|
+
case "success":
|
|
1472
|
+
frag.appendChild(k(ev.key)); frag.appendChild(plain(" succeeded "));
|
|
1473
|
+
frag.appendChild(dim("in " + fmtDuration(ev.durationMs) + (ev.attempts > 1 ? ", after " + ev.attempts + " attempts" : "")));
|
|
1474
|
+
break;
|
|
1475
|
+
case "error":
|
|
1476
|
+
frag.appendChild(k(ev.key)); frag.appendChild(plain(" errored: " + ev.error));
|
|
1477
|
+
frag.appendChild(dim(ev.willRetry ? " (retrying)" : " (gave up)"));
|
|
1478
|
+
break;
|
|
1479
|
+
case "timeout":
|
|
1480
|
+
frag.appendChild(k(ev.key)); frag.appendChild(plain(" timed out "));
|
|
1481
|
+
frag.appendChild(dim("after " + fmtDuration(ev.durationMs) + (ev.willRetry ? ", retrying" : ", gave up")));
|
|
1482
|
+
break;
|
|
1483
|
+
case "retry":
|
|
1484
|
+
frag.appendChild(k(ev.key));
|
|
1485
|
+
frag.appendChild(dim(" retrying in " + fmtDuration(ev.delayMs) + " (attempt " + ev.attempt + ")"));
|
|
1486
|
+
break;
|
|
1487
|
+
case "skipped":
|
|
1488
|
+
frag.appendChild(k(ev.key)); frag.appendChild(dim(" skipped, previous run still in flight"));
|
|
1489
|
+
break;
|
|
1490
|
+
case "scheduled":
|
|
1491
|
+
// This event's timestamp is the *next fire*, not the moment it was emitted. Saying so keeps
|
|
1492
|
+
// the feed's right-hand clock strictly the arrival time and never looks out of order.
|
|
1493
|
+
frag.appendChild(k(ev.key)); frag.appendChild(dim(" scheduled for " + clockTime(ev.at)));
|
|
1494
|
+
break;
|
|
1495
|
+
case "engine-start":
|
|
1496
|
+
frag.appendChild(plain("Engine started with " + ev.jobs + " job" + (ev.jobs === 1 ? "" : "s")));
|
|
1497
|
+
break;
|
|
1498
|
+
case "engine-stop":
|
|
1499
|
+
frag.appendChild(plain("Engine stopped"));
|
|
1500
|
+
break;
|
|
1501
|
+
default:
|
|
1502
|
+
frag.appendChild(plain(ev.type));
|
|
921
1503
|
}
|
|
1504
|
+
return frag;
|
|
922
1505
|
}
|
|
1506
|
+
|
|
923
1507
|
function feedClass(type) {
|
|
924
1508
|
if (type === "engine-start" || type === "engine-stop" || type === "scheduled") return "engine";
|
|
925
1509
|
return type;
|
|
926
1510
|
}
|
|
1511
|
+
|
|
927
1512
|
function pushEvent(ev) {
|
|
928
1513
|
var feed = el("feed");
|
|
929
|
-
|
|
1514
|
+
var firstChild = feed.firstChild;
|
|
1515
|
+
if (firstChild && firstChild.className === "empty") feed.textContent = "";
|
|
1516
|
+
|
|
930
1517
|
var div = document.createElement("div");
|
|
931
1518
|
div.className = "ev " + feedClass(ev.type);
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
1519
|
+
|
|
1520
|
+
var g = document.createElement("div");
|
|
1521
|
+
g.className = "glyph"; g.textContent = glyphs[ev.type] || "\\u2022";
|
|
1522
|
+
var b = document.createElement("div");
|
|
1523
|
+
b.className = "body"; b.appendChild(describe(ev));
|
|
1524
|
+
var w = document.createElement("div");
|
|
1525
|
+
w.className = "when";
|
|
1526
|
+
w.textContent = clockTime(ev.type === "scheduled" ? Date.now() : (ev.at || Date.now()));
|
|
1527
|
+
|
|
1528
|
+
div.appendChild(g); div.appendChild(b); div.appendChild(w);
|
|
936
1529
|
feed.insertBefore(div, feed.firstChild);
|
|
937
1530
|
while (feed.childNodes.length > MAX_FEED) feed.removeChild(feed.lastChild);
|
|
938
|
-
|
|
939
|
-
// A terminal event changes a job's last status / running flag \u2014 refresh the table.
|
|
940
|
-
if (ev.type === "success" || ev.type === "error" || ev.type === "timeout" || ev.type === "skipped" || ev.type === "scheduled") {
|
|
941
|
-
loadJobs();
|
|
942
|
-
}
|
|
943
1531
|
}
|
|
944
1532
|
|
|
945
|
-
function
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
1533
|
+
el("feed-pause").addEventListener("click", function () {
|
|
1534
|
+
paused = !paused;
|
|
1535
|
+
this.textContent = paused ? "Resume" : "Pause";
|
|
1536
|
+
this.setAttribute("aria-pressed", paused ? "true" : "false");
|
|
1537
|
+
if (!paused) { pausedCount = 0; el("feed-paused").hidden = true; }
|
|
1538
|
+
});
|
|
1539
|
+
el("feed-clear").addEventListener("click", function () {
|
|
1540
|
+
el("feed").textContent = "";
|
|
1541
|
+
var d = document.createElement("div");
|
|
1542
|
+
d.className = "empty"; d.textContent = "Cleared. New events appear here.";
|
|
1543
|
+
el("feed").appendChild(d);
|
|
1544
|
+
});
|
|
1545
|
+
|
|
1546
|
+
// \u2500\u2500 Connection \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
1547
|
+
function setConn(stateName) {
|
|
1548
|
+
var chip = el("conn");
|
|
1549
|
+
chip.className = "chip " + stateName;
|
|
1550
|
+
setText(el("conn-text"), stateName === "live" ? "live" : (stateName === "down" ? "reconnecting" : "connecting\\u2026"));
|
|
1551
|
+
}
|
|
1552
|
+
function setEngineChip() {
|
|
1553
|
+
var chip = el("engine-chip");
|
|
1554
|
+
var running = state.engine.running;
|
|
1555
|
+
chip.className = "chip" + (running ? " live" : "");
|
|
1556
|
+
var n = state.jobs.length;
|
|
1557
|
+
setText(el("engine-text"), (running ? "running" : "stopped") + " \\u00b7 " + n + " job" + (n === 1 ? "" : "s"));
|
|
949
1558
|
}
|
|
950
1559
|
|
|
951
1560
|
function connect() {
|
|
952
1561
|
setConn("connecting");
|
|
953
1562
|
var src = new EventSource("/api/events");
|
|
954
|
-
src.onopen = function () { setConn("live"); };
|
|
1563
|
+
src.onopen = function () { setConn("live"); scheduleRefresh(0); };
|
|
955
1564
|
src.onmessage = function (m) {
|
|
956
|
-
|
|
1565
|
+
var ev;
|
|
1566
|
+
try { ev = JSON.parse(m.data); } catch (e) { return; }
|
|
1567
|
+
if (paused) {
|
|
1568
|
+
pausedCount++;
|
|
1569
|
+
el("feed-paused").hidden = false;
|
|
1570
|
+
el("feed-paused").textContent = "Paused \\u00b7 " + pausedCount + " event" + (pausedCount === 1 ? "" : "s") + " not shown";
|
|
1571
|
+
} else {
|
|
1572
|
+
pushEvent(ev);
|
|
1573
|
+
}
|
|
1574
|
+
// Terminal events change job status / history; reconcile on the next tick.
|
|
1575
|
+
if (ev.type !== "fire") scheduleRefresh(DEBOUNCE_MS);
|
|
957
1576
|
};
|
|
958
1577
|
src.onerror = function () { setConn("down"); };
|
|
959
1578
|
}
|
|
960
1579
|
|
|
961
|
-
// \u2500\u2500
|
|
1580
|
+
// \u2500\u2500 State \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
1581
|
+
function scheduleRefresh(delay) {
|
|
1582
|
+
if (refreshTimer) return;
|
|
1583
|
+
refreshTimer = setTimeout(function () { refreshTimer = null; refresh(); }, delay);
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
function refresh() {
|
|
1587
|
+
return fetch("/api/state?upcoming=" + UPCOMING)
|
|
1588
|
+
.then(function (r) { return r.json(); })
|
|
1589
|
+
.then(function (next) {
|
|
1590
|
+
var structureChanged = !sameKeys(next.jobs);
|
|
1591
|
+
state = next;
|
|
1592
|
+
jobsByKey = {};
|
|
1593
|
+
for (var i = 0; i < next.jobs.length; i++) jobsByKey[next.jobs[i].key] = next.jobs[i];
|
|
1594
|
+
|
|
1595
|
+
if (structureChanged) { buildRows(next.jobs); buildLanes(next.jobs); built = true; }
|
|
1596
|
+
paintRows(next.jobs);
|
|
1597
|
+
paintTimeline();
|
|
1598
|
+
renderStats();
|
|
1599
|
+
setEngineChip();
|
|
1600
|
+
if (openKey) paintDrawer(openKey);
|
|
1601
|
+
})
|
|
1602
|
+
.catch(function () { /* the poll below retries */ });
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
// \u2500\u2500 Drawer \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
962
1606
|
function openDrawer(key) {
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
el("
|
|
1607
|
+
if (!jobsByKey[key]) return;
|
|
1608
|
+
openKey = key;
|
|
1609
|
+
lastFocus = document.activeElement;
|
|
1610
|
+
el("overlay").hidden = false;
|
|
967
1611
|
el("overlay").classList.add("open");
|
|
1612
|
+
paintDrawer(key);
|
|
1613
|
+
el("drawer-close").focus();
|
|
1614
|
+
}
|
|
968
1615
|
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
'<div class="kv"><span>Schedule</span><code>' + esc(job.schedule) + "</code></div>" +
|
|
976
|
-
'<div class="kv"><span>Timezone</span><span>' + esc(job.timeZone) + "</span></div>" +
|
|
977
|
-
(job.description ? '<div class="kv"><span>Description</span><span>' + esc(job.description) + "</span></div>" : "") +
|
|
978
|
-
'<div style="margin-top:14px"><button class="primary" id="run-now">Run now</button></div>' +
|
|
979
|
-
'<div class="section-title">Next fire times</div>';
|
|
980
|
-
if (fires.length) {
|
|
981
|
-
html += '<ul class="plain">';
|
|
982
|
-
for (var i = 0; i < fires.length; i++) html += "<li><span>" + esc(new Date(fires[i]).toLocaleString()) + "</span><span class=\\"count\\">" + countdown(fires[i]) + "</span></li>";
|
|
983
|
-
html += "</ul>";
|
|
984
|
-
} else { html += '<div class="empty">No upcoming fires.</div>'; }
|
|
985
|
-
|
|
986
|
-
html += '<div class="section-title">Recent runs</div>';
|
|
987
|
-
if (runs.length) {
|
|
988
|
-
html += '<ul class="plain">';
|
|
989
|
-
for (var j = 0; j < runs.length && j < 20; j++) {
|
|
990
|
-
var run = runs[j];
|
|
991
|
-
html += "<li><span>" + statusBadge(run.status) + " <span class=\\"when\\">" + esc(new Date(run.startedAt).toLocaleString()) + "</span></span><span>" + fmtDuration(run.durationMs) + "</span></li>";
|
|
992
|
-
}
|
|
993
|
-
html += "</ul>";
|
|
994
|
-
} else { html += '<div class="empty">No runs yet.</div>'; }
|
|
1616
|
+
function closeDrawer() {
|
|
1617
|
+
openKey = null;
|
|
1618
|
+
el("overlay").classList.remove("open");
|
|
1619
|
+
el("overlay").hidden = true;
|
|
1620
|
+
if (lastFocus && lastFocus.focus) lastFocus.focus();
|
|
1621
|
+
}
|
|
995
1622
|
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
1623
|
+
function runLine(run) {
|
|
1624
|
+
var li = document.createElement("li");
|
|
1625
|
+
var wrap = document.createElement("div");
|
|
1626
|
+
wrap.className = "runline";
|
|
1627
|
+
|
|
1628
|
+
var row1 = document.createElement("div");
|
|
1629
|
+
row1.className = "row1";
|
|
1630
|
+
var left = document.createElement("span");
|
|
1631
|
+
left.appendChild(badge(run.status));
|
|
1632
|
+
var when = document.createElement("span");
|
|
1633
|
+
when.className = "meta";
|
|
1634
|
+
when.textContent = " " + localTime(new Date(run.startedAt).toISOString());
|
|
1635
|
+
left.appendChild(when);
|
|
1636
|
+
var right = document.createElement("span");
|
|
1637
|
+
right.className = "meta";
|
|
1638
|
+
right.textContent = fmtDuration(run.durationMs) + (run.attempts > 1 ? " \\u00b7 " + run.attempts + " attempts" : "");
|
|
1639
|
+
row1.appendChild(left); row1.appendChild(right);
|
|
1640
|
+
wrap.appendChild(row1);
|
|
1641
|
+
|
|
1642
|
+
if (run.error) {
|
|
1643
|
+
var pre = document.createElement("pre");
|
|
1644
|
+
pre.className = "err"; pre.textContent = run.error;
|
|
1645
|
+
wrap.appendChild(pre);
|
|
1646
|
+
} else if (run.result !== undefined && run.result !== null) {
|
|
1647
|
+
var pre2 = document.createElement("pre");
|
|
1648
|
+
try { pre2.textContent = JSON.stringify(run.result, null, 2); }
|
|
1649
|
+
catch (e) { pre2.textContent = String(run.result); }
|
|
1650
|
+
wrap.appendChild(pre2);
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
li.appendChild(wrap);
|
|
1654
|
+
li.style.display = "block";
|
|
1655
|
+
return li;
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
function paintDrawer(key) {
|
|
1659
|
+
var job = jobsByKey[key];
|
|
1660
|
+
if (!job) { closeDrawer(); return; }
|
|
1661
|
+
setText(el("drawer-title"), key);
|
|
1662
|
+
|
|
1663
|
+
var body = el("drawer-body");
|
|
1664
|
+
body.textContent = "";
|
|
1665
|
+
|
|
1666
|
+
function kv(label, valueNode) {
|
|
1667
|
+
var d = document.createElement("div");
|
|
1668
|
+
d.className = "kv";
|
|
1669
|
+
var l = document.createElement("span"); l.textContent = label;
|
|
1670
|
+
d.appendChild(l); d.appendChild(valueNode);
|
|
1671
|
+
body.appendChild(d);
|
|
1672
|
+
}
|
|
1673
|
+
function textNode(t) { var s = document.createElement("span"); s.textContent = t; return s; }
|
|
1674
|
+
function codeNode(t) { var s = document.createElement("code"); s.textContent = t; return s; }
|
|
1675
|
+
|
|
1676
|
+
kv("Schedule", codeNode(job.schedule));
|
|
1677
|
+
kv("Time zone", textNode(job.timeZone));
|
|
1678
|
+
if (job.description) kv("Description", textNode(job.description));
|
|
1679
|
+
kv("Status", job.running ? badge("running") : badge(job.lastRun ? job.lastRun.status : ""));
|
|
1680
|
+
|
|
1681
|
+
var t1 = document.createElement("div");
|
|
1682
|
+
t1.className = "section-title"; t1.textContent = "Next fire times";
|
|
1683
|
+
body.appendChild(t1);
|
|
1684
|
+
|
|
1685
|
+
var ups = job.upcoming || [];
|
|
1686
|
+
if (ups.length) {
|
|
1687
|
+
var ul = document.createElement("ul");
|
|
1688
|
+
ul.className = "plain";
|
|
1689
|
+
for (var i = 0; i < ups.length && i < 8; i++) {
|
|
1690
|
+
var li = document.createElement("li");
|
|
1691
|
+
var a = document.createElement("span"); a.textContent = localTime(ups[i]);
|
|
1692
|
+
var b = document.createElement("span"); b.className = "meta num"; b.textContent = countdown(ups[i]);
|
|
1693
|
+
li.appendChild(a); li.appendChild(b);
|
|
1694
|
+
ul.appendChild(li);
|
|
1695
|
+
}
|
|
1696
|
+
body.appendChild(ul);
|
|
1697
|
+
} else {
|
|
1698
|
+
var e1 = document.createElement("div");
|
|
1699
|
+
e1.className = "empty"; e1.textContent = "No upcoming fires for this expression.";
|
|
1700
|
+
body.appendChild(e1);
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
var t2 = document.createElement("div");
|
|
1704
|
+
t2.className = "section-title"; t2.textContent = "Recent runs";
|
|
1705
|
+
body.appendChild(t2);
|
|
1706
|
+
|
|
1707
|
+
var mine = [];
|
|
1708
|
+
for (var r = 0; r < state.runs.length; r++) if (state.runs[r].key === key) mine.push(state.runs[r]);
|
|
1709
|
+
if (mine.length) {
|
|
1710
|
+
var ul2 = document.createElement("ul");
|
|
1711
|
+
ul2.className = "plain";
|
|
1712
|
+
for (var m = 0; m < mine.length && m < 25; m++) ul2.appendChild(runLine(mine[m]));
|
|
1713
|
+
body.appendChild(ul2);
|
|
1714
|
+
} else {
|
|
1715
|
+
var e2 = document.createElement("div");
|
|
1716
|
+
e2.className = "empty"; e2.textContent = "No runs yet in this session.";
|
|
1717
|
+
body.appendChild(e2);
|
|
1718
|
+
}
|
|
999
1719
|
}
|
|
1000
|
-
function closeDrawer() { el("overlay").classList.remove("open"); }
|
|
1001
|
-
el("drawer-close").addEventListener("click", closeDrawer);
|
|
1002
|
-
el("overlay").addEventListener("click", function (e) { if (e.target === el("overlay")) closeDrawer(); });
|
|
1003
1720
|
|
|
1004
1721
|
function runNow(key, btn) {
|
|
1005
|
-
|
|
1722
|
+
var label = btn.textContent;
|
|
1723
|
+
btn.disabled = true; btn.textContent = "Running\\u2026";
|
|
1006
1724
|
fetch("/api/trigger/" + encodeURIComponent(key), { method: "POST", headers: { "content-type": "application/json" } })
|
|
1007
1725
|
.then(function (r) { return r.json(); })
|
|
1008
|
-
.then(function () { btn.textContent = "Ran
|
|
1009
|
-
.catch(function () { btn.
|
|
1726
|
+
.then(function () { btn.textContent = "Ran"; scheduleRefresh(0); })
|
|
1727
|
+
.catch(function () { btn.textContent = "Failed"; })
|
|
1728
|
+
.then(function () {
|
|
1729
|
+
setTimeout(function () { btn.disabled = false; btn.textContent = label; }, 1200);
|
|
1730
|
+
});
|
|
1010
1731
|
}
|
|
1011
1732
|
|
|
1733
|
+
el("drawer-close").addEventListener("click", closeDrawer);
|
|
1734
|
+
el("drawer-run").addEventListener("click", function () { if (openKey) runNow(openKey, this); });
|
|
1735
|
+
el("overlay").addEventListener("click", function (e) { if (e.target === el("overlay")) closeDrawer(); });
|
|
1736
|
+
document.addEventListener("keydown", function (e) {
|
|
1737
|
+
if (e.key === "Escape" && openKey) closeDrawer();
|
|
1738
|
+
});
|
|
1739
|
+
|
|
1740
|
+
// Row interactions are delegated, so rebuilding the table never leaks listeners.
|
|
1741
|
+
el("jobs").addEventListener("click", function (e) {
|
|
1742
|
+
var btn = e.target.closest ? e.target.closest("button[data-key]") : null;
|
|
1743
|
+
if (btn) { e.stopPropagation(); runNow(btn.dataset.key, btn); return; }
|
|
1744
|
+
var tr = e.target.closest ? e.target.closest("tr.job") : null;
|
|
1745
|
+
if (tr) openDrawer(tr.dataset.key);
|
|
1746
|
+
});
|
|
1747
|
+
el("jobs").addEventListener("keydown", function (e) {
|
|
1748
|
+
if (e.key !== "Enter" && e.key !== " ") return;
|
|
1749
|
+
var tr = e.target.closest ? e.target.closest("tr.job") : null;
|
|
1750
|
+
if (tr) { e.preventDefault(); openDrawer(tr.dataset.key); }
|
|
1751
|
+
});
|
|
1752
|
+
el("tl-gutter").addEventListener("click", function (e) {
|
|
1753
|
+
var label = e.target.closest ? e.target.closest(".lane-label") : null;
|
|
1754
|
+
if (label) openDrawer(label.textContent);
|
|
1755
|
+
});
|
|
1756
|
+
|
|
1012
1757
|
// \u2500\u2500 Boot \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
1013
|
-
|
|
1758
|
+
refresh();
|
|
1014
1759
|
connect();
|
|
1015
1760
|
setInterval(tickCountdowns, 1000);
|
|
1016
|
-
setInterval(
|
|
1761
|
+
setInterval(slideTimeline, 1000);
|
|
1762
|
+
setInterval(function () { scheduleRefresh(0); }, REFRESH_MS);
|
|
1017
1763
|
})();
|
|
1018
1764
|
</script>
|
|
1019
1765
|
</body>
|
|
@@ -1070,7 +1816,38 @@ function handleRequest(engine, sseClients, req, res) {
|
|
|
1070
1816
|
return openEventStream(engine, sseClients, req, res);
|
|
1071
1817
|
}
|
|
1072
1818
|
if (method === "GET" && path === "/api/health") {
|
|
1073
|
-
return sendJson(res, 200, {
|
|
1819
|
+
return sendJson(res, 200, {
|
|
1820
|
+
ok: true,
|
|
1821
|
+
jobs: engine.jobs().length,
|
|
1822
|
+
activeRuns: engine.activeRuns,
|
|
1823
|
+
running: engine.running,
|
|
1824
|
+
startedAt: engine.startedAt
|
|
1825
|
+
});
|
|
1826
|
+
}
|
|
1827
|
+
if (method === "GET" && path === "/api/state") {
|
|
1828
|
+
const runs = engine.runs();
|
|
1829
|
+
const lastByKey = /* @__PURE__ */ new Map();
|
|
1830
|
+
for (const run of runs) if (!lastByKey.has(run.key)) lastByKey.set(run.key, run);
|
|
1831
|
+
const upcomingCount = clampCount(url.searchParams.get("upcoming"));
|
|
1832
|
+
return sendJson(res, 200, {
|
|
1833
|
+
engine: {
|
|
1834
|
+
running: engine.running,
|
|
1835
|
+
startedAt: engine.startedAt,
|
|
1836
|
+
activeRuns: engine.activeRuns,
|
|
1837
|
+
now: Date.now()
|
|
1838
|
+
},
|
|
1839
|
+
jobs: engine.snapshot().map((j) => ({
|
|
1840
|
+
key: j.key,
|
|
1841
|
+
schedule: j.schedule,
|
|
1842
|
+
timeZone: j.timeZone,
|
|
1843
|
+
description: j.description ?? null,
|
|
1844
|
+
nextFire: j.nextFire ? j.nextFire.toISOString() : null,
|
|
1845
|
+
running: j.running,
|
|
1846
|
+
lastRun: lastByKey.get(j.key) ?? null,
|
|
1847
|
+
upcoming: upcoming(j.schedule, j.timeZone, upcomingCount)
|
|
1848
|
+
})),
|
|
1849
|
+
runs
|
|
1850
|
+
});
|
|
1074
1851
|
}
|
|
1075
1852
|
if (method === "GET" && path === "/api/runs.ndjson") {
|
|
1076
1853
|
res.writeHead(200, { "content-type": "application/x-ndjson; charset=utf-8", "cache-control": "no-store" });
|
|
@@ -1153,6 +1930,13 @@ function sendHtml(res, html) {
|
|
|
1153
1930
|
function decodeKey(path, prefix) {
|
|
1154
1931
|
return decodeURIComponent(path.slice(prefix.length));
|
|
1155
1932
|
}
|
|
1933
|
+
function upcoming(schedule, timeZone, count) {
|
|
1934
|
+
try {
|
|
1935
|
+
return previewSchedule(schedule, { timeZone, count }).map((d) => d.toISOString());
|
|
1936
|
+
} catch {
|
|
1937
|
+
return [];
|
|
1938
|
+
}
|
|
1939
|
+
}
|
|
1156
1940
|
function clampCount(raw) {
|
|
1157
1941
|
const n = raw ? Number.parseInt(raw, 10) : 5;
|
|
1158
1942
|
if (!Number.isFinite(n) || n < 1) return 5;
|