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