@oneuptime/common 12.0.19 → 12.0.20
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/Server/API/EnterpriseLicenseAPI.ts +94 -19
- package/Server/EnvironmentConfig.ts +17 -0
- package/Server/Services/ProjectService.ts +104 -0
- package/Server/Utils/Response.ts +2 -2
- package/Server/Utils/StartServer.ts +2 -2
- package/Server/Views/Partials/AnalyticsConsent.ejs +533 -0
- package/Tests/App/Dashboard/AlertEpisodeViewFields.test.tsx +465 -0
- package/Tests/App/Dashboard/DashboardChartWidgetZoom.test.tsx +302 -0
- package/Tests/App/Dashboard/DiscoveryScanWizardValidation.test.tsx +408 -0
- package/Tests/App/Dashboard/IncidentEpisodeViewFields.test.tsx +474 -0
- package/Tests/App/Dashboard/MetricSeriesInvestigateMenu.test.tsx +368 -0
- package/Tests/Server/API/EnterpriseLicenseReportUserCount.test.ts +763 -0
- package/Tests/Server/Services/ProjectServiceChangePlan.test.ts +138 -0
- package/Tests/Server/Services/ProjectServiceCreateSubscriptionStarted.test.ts +516 -0
- package/Tests/Server/Utils/ResponseRenderAnalytics.test.ts +53 -0
- package/Tests/Server/Views/AnalyticsConsentPartial.test.ts +241 -0
- package/Tests/UI/Components/Charts/ChartTooltipSorted.test.tsx +129 -0
- package/Tests/UI/Components/Charts/TooltipEntries.test.ts +155 -0
- package/Tests/UI/Components/Detail/DetailFieldErrors.test.tsx +347 -0
- package/Tests/UI/Components/Forms/BasicFormStepValidation.test.tsx +392 -0
- package/Tests/UI/Components/Forms/ValidationFormSteps.test.ts +549 -0
- package/Tests/Utils/EnterpriseLicenseSync.test.ts +562 -0
- package/Types/Analytics/RevenueEvent.ts +1 -0
- package/Types/Marketing/MarketingEvent.ts +49 -6
- package/UI/Components/Charts/Area/AreaChart.tsx +8 -1
- package/UI/Components/Charts/Bar/BarChart.tsx +8 -1
- package/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.tsx +19 -4
- package/UI/Components/Charts/ChartLibrary/BarChart/BarChart.tsx +20 -2
- package/UI/Components/Charts/ChartLibrary/LineChart/LineChart.tsx +19 -4
- package/UI/Components/Charts/ChartLibrary/Utils/TooltipEntries.ts +105 -0
- package/UI/Components/Charts/Line/LineChart.tsx +8 -1
- package/UI/Components/Detail/Detail.tsx +18 -1
- package/Utils/EnterpriseLicense/EnterpriseLicenseSync.ts +244 -0
- package/build/dist/Server/API/EnterpriseLicenseAPI.js +66 -12
- package/build/dist/Server/API/EnterpriseLicenseAPI.js.map +1 -1
- package/build/dist/Server/EnvironmentConfig.js +15 -0
- package/build/dist/Server/EnvironmentConfig.js.map +1 -1
- package/build/dist/Server/Services/ProjectService.js +88 -0
- package/build/dist/Server/Services/ProjectService.js.map +1 -1
- package/build/dist/Server/Utils/Response.js +2 -2
- package/build/dist/Server/Utils/Response.js.map +1 -1
- package/build/dist/Server/Utils/StartServer.js +2 -2
- package/build/dist/Server/Utils/StartServer.js.map +1 -1
- package/build/dist/Types/Analytics/RevenueEvent.js +1 -0
- package/build/dist/Types/Analytics/RevenueEvent.js.map +1 -1
- package/build/dist/Types/Marketing/MarketingEvent.js +40 -1
- package/build/dist/Types/Marketing/MarketingEvent.js.map +1 -1
- package/build/dist/UI/Components/Charts/Area/AreaChart.js +9 -1
- package/build/dist/UI/Components/Charts/Area/AreaChart.js.map +1 -1
- package/build/dist/UI/Components/Charts/Bar/BarChart.js +9 -1
- package/build/dist/UI/Components/Charts/Bar/BarChart.js.map +1 -1
- package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js +22 -13
- package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js.map +1 -1
- package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js +27 -15
- package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js.map +1 -1
- package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js +27 -18
- package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js.map +1 -1
- package/build/dist/UI/Components/Charts/ChartLibrary/Utils/TooltipEntries.js +71 -0
- package/build/dist/UI/Components/Charts/ChartLibrary/Utils/TooltipEntries.js.map +1 -0
- package/build/dist/UI/Components/Charts/Line/LineChart.js +9 -1
- package/build/dist/UI/Components/Charts/Line/LineChart.js.map +1 -1
- package/build/dist/UI/Components/Detail/Detail.js +17 -1
- package/build/dist/UI/Components/Detail/Detail.js.map +1 -1
- package/build/dist/Utils/EnterpriseLicense/EnterpriseLicenseSync.js +174 -0
- package/build/dist/Utils/EnterpriseLicense/EnterpriseLicenseSync.js.map +1 -0
- package/package.json +1 -1
|
@@ -39,6 +39,10 @@ import {
|
|
|
39
39
|
import { cx } from "../Utils/Cx";
|
|
40
40
|
import { getYAxisDomain } from "../Utils/GetYAxisDomain";
|
|
41
41
|
import { hasOnlyOneValueForKey } from "../Utils/HasOnlyOneValueForKey";
|
|
42
|
+
import {
|
|
43
|
+
PreparedTooltipEntries,
|
|
44
|
+
prepareTooltipEntries,
|
|
45
|
+
} from "../Utils/TooltipEntries";
|
|
42
46
|
import ChartCurve from "../../Types/ChartCurve";
|
|
43
47
|
|
|
44
48
|
/*
|
|
@@ -475,9 +479,15 @@ const ChartTooltip: ({
|
|
|
475
479
|
valueFormatter,
|
|
476
480
|
}: ChartTooltipProps): React.JSX.Element | null => {
|
|
477
481
|
if (active && payload && payload.length) {
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
482
|
+
/*
|
|
483
|
+
* Highest value at the hovered timestamp first, capped — on a grouped
|
|
484
|
+
* chart (one series per host/pod), the spiking series must be the
|
|
485
|
+
* first line of the tooltip, not buried at its alphabetical position.
|
|
486
|
+
*/
|
|
487
|
+
const {
|
|
488
|
+
entries: legendPayload,
|
|
489
|
+
overflowCount,
|
|
490
|
+
}: PreparedTooltipEntries<PayloadItem> = prepareTooltipEntries(payload);
|
|
481
491
|
return (
|
|
482
492
|
<div
|
|
483
493
|
className={cx(
|
|
@@ -531,6 +541,11 @@ const ChartTooltip: ({
|
|
|
531
541
|
);
|
|
532
542
|
},
|
|
533
543
|
)}
|
|
544
|
+
{overflowCount > 0 ? (
|
|
545
|
+
<p className={cx("pt-1 text-xs", "text-gray-400")}>
|
|
546
|
+
+{overflowCount} more series — highest values shown
|
|
547
|
+
</p>
|
|
548
|
+
) : null}
|
|
534
549
|
</div>
|
|
535
550
|
</div>
|
|
536
551
|
);
|
|
@@ -1426,4 +1441,4 @@ const AreaChart: React.ForwardRefExoticComponent<
|
|
|
1426
1441
|
|
|
1427
1442
|
AreaChart.displayName = "AreaChart";
|
|
1428
1443
|
|
|
1429
|
-
export { AreaChart, type AreaChartEventProps, type TooltipProps };
|
|
1444
|
+
export { AreaChart, ChartTooltip, type AreaChartEventProps, type TooltipProps };
|
|
@@ -33,6 +33,10 @@ import {
|
|
|
33
33
|
} from "../Utils/ChartColors";
|
|
34
34
|
import { cx } from "../Utils/Cx";
|
|
35
35
|
import { getYAxisDomain } from "../Utils/GetYAxisDomain";
|
|
36
|
+
import {
|
|
37
|
+
PreparedTooltipEntries,
|
|
38
|
+
prepareTooltipEntries,
|
|
39
|
+
} from "../Utils/TooltipEntries";
|
|
36
40
|
import { useOnWindowResize } from "../Utils/UseWindowOnResize";
|
|
37
41
|
|
|
38
42
|
/*
|
|
@@ -562,6 +566,15 @@ const ChartTooltip: React.FunctionComponent<ChartTooltipProps> = ({
|
|
|
562
566
|
valueFormatter,
|
|
563
567
|
}: ChartTooltipProps): React.ReactElement | null => {
|
|
564
568
|
if (active && payload && payload.length) {
|
|
569
|
+
/*
|
|
570
|
+
* Highest value at the hovered bucket first, capped — on a grouped
|
|
571
|
+
* chart (one series per host/pod), the spiking series must be the
|
|
572
|
+
* first line of the tooltip, not buried at its alphabetical position.
|
|
573
|
+
*/
|
|
574
|
+
const {
|
|
575
|
+
entries: sortedPayload,
|
|
576
|
+
overflowCount,
|
|
577
|
+
}: PreparedTooltipEntries<PayloadItem> = prepareTooltipEntries(payload);
|
|
565
578
|
return (
|
|
566
579
|
<div
|
|
567
580
|
className={cx(
|
|
@@ -586,7 +599,7 @@ const ChartTooltip: React.FunctionComponent<ChartTooltipProps> = ({
|
|
|
586
599
|
</p>
|
|
587
600
|
</div>
|
|
588
601
|
<div className={cx("space-y-1 px-4 py-2")}>
|
|
589
|
-
{
|
|
602
|
+
{sortedPayload.map(
|
|
590
603
|
({ value, category, color }: PayloadItem, index: number) => {
|
|
591
604
|
return (
|
|
592
605
|
<div
|
|
@@ -631,6 +644,11 @@ const ChartTooltip: React.FunctionComponent<ChartTooltipProps> = ({
|
|
|
631
644
|
);
|
|
632
645
|
},
|
|
633
646
|
)}
|
|
647
|
+
{overflowCount > 0 ? (
|
|
648
|
+
<p className={cx("pt-1 text-xs", "text-gray-400")}>
|
|
649
|
+
+{overflowCount} more series — highest values shown
|
|
650
|
+
</p>
|
|
651
|
+
) : null}
|
|
634
652
|
</div>
|
|
635
653
|
</div>
|
|
636
654
|
);
|
|
@@ -1194,4 +1212,4 @@ const BarChart: React.ForwardRefExoticComponent<
|
|
|
1194
1212
|
|
|
1195
1213
|
BarChart.displayName = "BarChart";
|
|
1196
1214
|
|
|
1197
|
-
export { BarChart, type BarChartEventProps, type TooltipProps };
|
|
1215
|
+
export { BarChart, ChartTooltip, type BarChartEventProps, type TooltipProps };
|
|
@@ -40,6 +40,10 @@ import {
|
|
|
40
40
|
import { cx } from "../Utils/Cx";
|
|
41
41
|
import { getYAxisDomain } from "../Utils/GetYAxisDomain";
|
|
42
42
|
import { hasOnlyOneValueForKey } from "../Utils/HasOnlyOneValueForKey";
|
|
43
|
+
import {
|
|
44
|
+
PreparedTooltipEntries,
|
|
45
|
+
prepareTooltipEntries,
|
|
46
|
+
} from "../Utils/TooltipEntries";
|
|
43
47
|
import ChartCurve from "../../Types/ChartCurve";
|
|
44
48
|
|
|
45
49
|
/*
|
|
@@ -478,9 +482,15 @@ const ChartTooltip: ({
|
|
|
478
482
|
valueFormatter,
|
|
479
483
|
}: ChartTooltipProps): React.JSX.Element | null => {
|
|
480
484
|
if (active && payload && payload.length) {
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
485
|
+
/*
|
|
486
|
+
* Highest value at the hovered timestamp first, capped — on a grouped
|
|
487
|
+
* chart (one series per host/pod), the spiking series must be the
|
|
488
|
+
* first line of the tooltip, not buried at its alphabetical position.
|
|
489
|
+
*/
|
|
490
|
+
const {
|
|
491
|
+
entries: legendPayload,
|
|
492
|
+
overflowCount,
|
|
493
|
+
}: PreparedTooltipEntries<PayloadItem> = prepareTooltipEntries(payload);
|
|
484
494
|
return (
|
|
485
495
|
<div
|
|
486
496
|
className={cx(
|
|
@@ -550,6 +560,11 @@ const ChartTooltip: ({
|
|
|
550
560
|
);
|
|
551
561
|
},
|
|
552
562
|
)}
|
|
563
|
+
{overflowCount > 0 ? (
|
|
564
|
+
<p className={cx("pt-1 text-xs", "text-gray-400")}>
|
|
565
|
+
+{overflowCount} more series — highest values shown
|
|
566
|
+
</p>
|
|
567
|
+
) : null}
|
|
553
568
|
</div>
|
|
554
569
|
</div>
|
|
555
570
|
);
|
|
@@ -1419,4 +1434,4 @@ const LineChart: React.ForwardRefExoticComponent<
|
|
|
1419
1434
|
|
|
1420
1435
|
LineChart.displayName = "LineChart";
|
|
1421
1436
|
|
|
1422
|
-
export { LineChart, type LineChartEventProps, type TooltipProps };
|
|
1437
|
+
export { ChartTooltip, LineChart, type LineChartEventProps, type TooltipProps };
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Shared ordering/capping for the hover tooltip of the Area / Line / Bar
|
|
3
|
+
* charts. With a grouped metric (say 20 hosts on one chart) the recharts
|
|
4
|
+
* payload arrives in series-render order — effectively alphabetical — so
|
|
5
|
+
* the series that is actually spiking at the hovered timestamp is buried
|
|
6
|
+
* mid-list. Sorting by the value AT THE HOVERED POINT puts the top series
|
|
7
|
+
* first, and capping the list keeps a high-cardinality chart's tooltip
|
|
8
|
+
* readable (the "+N more" line reports what was elided).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/*
|
|
12
|
+
* The slice of a recharts tooltip payload item the ordering needs. The
|
|
13
|
+
* three chart implementations each declare their own structurally-equal
|
|
14
|
+
* PayloadItem, so this stays generic instead of importing any of them.
|
|
15
|
+
*/
|
|
16
|
+
export interface SortableTooltipItem {
|
|
17
|
+
category: string;
|
|
18
|
+
value: number;
|
|
19
|
+
type?: string | undefined;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const DEFAULT_TOOLTIP_MAX_ENTRIES: number = 10;
|
|
23
|
+
|
|
24
|
+
export interface PreparedTooltipEntries<T extends SortableTooltipItem> {
|
|
25
|
+
/** Entries to render, highest value first, capped at maxEntries. */
|
|
26
|
+
entries: Array<T>;
|
|
27
|
+
/** How many entries the cap hid (0 when everything fits). */
|
|
28
|
+
overflowCount: number;
|
|
29
|
+
/** Entry count after filtering, before capping. */
|
|
30
|
+
totalCount: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/*
|
|
34
|
+
* Natural compare so host names order like cpu0 < cpu2 < cpu10 instead of
|
|
35
|
+
* lexicographically — mirrors the series chips' name ordering.
|
|
36
|
+
*/
|
|
37
|
+
function compareCategoryNames(a: string, b: string): number {
|
|
38
|
+
return String(a).localeCompare(String(b), undefined, {
|
|
39
|
+
numeric: true,
|
|
40
|
+
sensitivity: "base",
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function prepareTooltipEntries<T extends SortableTooltipItem>(
|
|
45
|
+
payload: Array<T> | undefined | null,
|
|
46
|
+
maxEntries: number = DEFAULT_TOOLTIP_MAX_ENTRIES,
|
|
47
|
+
): PreparedTooltipEntries<T> {
|
|
48
|
+
/*
|
|
49
|
+
* `type === "none"` entries are series excluded from legends/tooltips
|
|
50
|
+
* (recharts tooltipType) — every chart filtered them before this
|
|
51
|
+
* module existed, so the filter lives here now to stay uniform.
|
|
52
|
+
*/
|
|
53
|
+
const visibleEntries: Array<T> = (payload || []).filter((item: T) => {
|
|
54
|
+
return item.type !== "none";
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const sortedEntries: Array<T> = visibleEntries
|
|
58
|
+
.slice()
|
|
59
|
+
.sort((a: T, b: T): number => {
|
|
60
|
+
/*
|
|
61
|
+
* Non-finite values (missing points, the anomaly band's [low, high]
|
|
62
|
+
* array) sort last — they carry no "how high is this series right
|
|
63
|
+
* now" signal, so they must never displace a real reading.
|
|
64
|
+
*/
|
|
65
|
+
const aValue: number | null =
|
|
66
|
+
typeof a.value === "number" && Number.isFinite(a.value)
|
|
67
|
+
? a.value
|
|
68
|
+
: null;
|
|
69
|
+
const bValue: number | null =
|
|
70
|
+
typeof b.value === "number" && Number.isFinite(b.value)
|
|
71
|
+
? b.value
|
|
72
|
+
: null;
|
|
73
|
+
|
|
74
|
+
if (aValue === null && bValue === null) {
|
|
75
|
+
return compareCategoryNames(a.category, b.category);
|
|
76
|
+
}
|
|
77
|
+
if (aValue === null) {
|
|
78
|
+
return 1;
|
|
79
|
+
}
|
|
80
|
+
if (bValue === null) {
|
|
81
|
+
return -1;
|
|
82
|
+
}
|
|
83
|
+
if (bValue !== aValue) {
|
|
84
|
+
return bValue - aValue;
|
|
85
|
+
}
|
|
86
|
+
return compareCategoryNames(a.category, b.category);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const totalCount: number = sortedEntries.length;
|
|
90
|
+
|
|
91
|
+
const cap: number =
|
|
92
|
+
Number.isInteger(maxEntries) && maxEntries > 0
|
|
93
|
+
? maxEntries
|
|
94
|
+
: DEFAULT_TOOLTIP_MAX_ENTRIES;
|
|
95
|
+
|
|
96
|
+
if (totalCount <= cap) {
|
|
97
|
+
return { entries: sortedEntries, overflowCount: 0, totalCount };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
entries: sortedEntries.slice(0, cap),
|
|
102
|
+
overflowCount: totalCount - cap,
|
|
103
|
+
totalCount,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
@@ -161,7 +161,14 @@ const LineChartElement: FunctionComponent<LineInternalProps> = (
|
|
|
161
161
|
|
|
162
162
|
return (
|
|
163
163
|
<div
|
|
164
|
-
|
|
164
|
+
/*
|
|
165
|
+
* `isolate` opens a stacking context so the recharts tooltip's
|
|
166
|
+
* z-index competes only inside this chart — without it the tooltip
|
|
167
|
+
* (zIndex 10, pinned to the chart's top edge) ties with the app
|
|
168
|
+
* header's sticky z-10 in the root context and paints over the
|
|
169
|
+
* navbar on half-scrolled charts.
|
|
170
|
+
*/
|
|
171
|
+
className="relative isolate flex flex-1"
|
|
165
172
|
style={props.heightInPx ? { height: `${props.heightInPx}px` } : undefined}
|
|
166
173
|
>
|
|
167
174
|
<LineChart
|
|
@@ -715,7 +715,24 @@ const Detail: DetailFunction = <T extends GenericObject>(
|
|
|
715
715
|
}
|
|
716
716
|
|
|
717
717
|
if (field.getElement) {
|
|
718
|
-
|
|
718
|
+
/*
|
|
719
|
+
* getField runs inside Detail's own render, so a throw out of a field
|
|
720
|
+
* renderer unwinds the whole route to the app level ErrorBoundary and
|
|
721
|
+
* replaces the page with "Something went wrong". One misbehaving field
|
|
722
|
+
* must never cost the entire page: log the misconfiguration and keep
|
|
723
|
+
* whatever this field type already rendered - the entity badge, or ""
|
|
724
|
+
* so the placeholder below can stand in.
|
|
725
|
+
*/
|
|
726
|
+
try {
|
|
727
|
+
data = field.getElement(props.item);
|
|
728
|
+
} catch (err) {
|
|
729
|
+
Logger.error(
|
|
730
|
+
"Detail: getElement for '" +
|
|
731
|
+
fieldKeyStr +
|
|
732
|
+
"' threw and was ignored. " +
|
|
733
|
+
(err instanceof Error ? err.message : String(err)),
|
|
734
|
+
);
|
|
735
|
+
}
|
|
719
736
|
}
|
|
720
737
|
|
|
721
738
|
let className: string = "sm:col-span-1";
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import GlobalConfig from "../../Models/DatabaseModels/GlobalConfig";
|
|
2
|
+
import PartialEntity from "../../Types/Database/PartialEntity";
|
|
3
|
+
import EnterpriseLicenseInstanceSummary from "../../Types/EnterpriseLicense/EnterpriseLicenseInstanceSummary";
|
|
4
|
+
import { JSONObject } from "../../Types/JSON";
|
|
5
|
+
|
|
6
|
+
export interface EnterpriseLicenseSyncResult {
|
|
7
|
+
/*
|
|
8
|
+
* Only the columns the license server actually spoke about. A column the
|
|
9
|
+
* response did not mention is absent from this object, so updateOneById
|
|
10
|
+
* leaves whatever is stored alone.
|
|
11
|
+
*/
|
|
12
|
+
updateData: PartialEntity<GlobalConfig>;
|
|
13
|
+
|
|
14
|
+
/*
|
|
15
|
+
* Fields the server sent in a shape this build cannot use. Returned rather
|
|
16
|
+
* than logged so this stays a pure function the tests can drive directly;
|
|
17
|
+
* the caller decides how loud to be about them.
|
|
18
|
+
*/
|
|
19
|
+
warnings: Array<string>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/*
|
|
23
|
+
* Maps a license-server response (from /enterprise-license/validate or
|
|
24
|
+
* /enterprise-license/report-user-count) onto the GlobalConfig columns a
|
|
25
|
+
* self-hosted installation mirrors locally.
|
|
26
|
+
*
|
|
27
|
+
* Every field is three-state, and the distinction is the whole contract:
|
|
28
|
+
*
|
|
29
|
+
* key absent - the license server did not speak about this field. That is
|
|
30
|
+
* what an oneuptime.com older than this build looks like, so
|
|
31
|
+
* the stored value is left exactly as it is. Collapsing this
|
|
32
|
+
* to null is how a daily report would wipe a perfectly good
|
|
33
|
+
* license expiry and take the installation down.
|
|
34
|
+
* null - the server explicitly says "there is no value". Written as
|
|
35
|
+
* null, so clearing a seat limit on oneuptime.com actually
|
|
36
|
+
* clears it on the customer's installation.
|
|
37
|
+
* a value - validated, then written.
|
|
38
|
+
*
|
|
39
|
+
* Anything that is neither absent, null, nor a usable value is treated as
|
|
40
|
+
* absent and reported as a warning: a malformed response must never be able
|
|
41
|
+
* to corrupt license state that was correct a moment ago.
|
|
42
|
+
*/
|
|
43
|
+
export default class EnterpriseLicenseSyncUtil {
|
|
44
|
+
/*
|
|
45
|
+
* The license key is deliberately NOT synced. The installation authenticates
|
|
46
|
+
* with the key it already holds, and a response must never be able to swap
|
|
47
|
+
* it for another one.
|
|
48
|
+
*/
|
|
49
|
+
public static getGlobalConfigUpdateFromLicenseResponse(data: {
|
|
50
|
+
payload: JSONObject;
|
|
51
|
+
/*
|
|
52
|
+
* Used as the "user count as of" stamp when the server reports a count but
|
|
53
|
+
* no timestamp for it. Passed in rather than read from the clock so the
|
|
54
|
+
* function stays pure.
|
|
55
|
+
*/
|
|
56
|
+
reportedAt: Date;
|
|
57
|
+
}): EnterpriseLicenseSyncResult {
|
|
58
|
+
const payload: JSONObject = data.payload || {};
|
|
59
|
+
const updateData: PartialEntity<GlobalConfig> = {};
|
|
60
|
+
const warnings: Array<string> = [];
|
|
61
|
+
|
|
62
|
+
/*
|
|
63
|
+
* The seat limit. This is the field customers change on oneuptime.com and
|
|
64
|
+
* then wait for: without it here, buying seats never reaches the
|
|
65
|
+
* installation that is enforcing the old number.
|
|
66
|
+
*/
|
|
67
|
+
if (this.isPresent(payload, "userLimit")) {
|
|
68
|
+
const rawUserLimit: unknown = payload["userLimit"];
|
|
69
|
+
|
|
70
|
+
if (rawUserLimit === null) {
|
|
71
|
+
updateData.enterpriseLicenseUserLimit = null;
|
|
72
|
+
} else if (this.isNonNegativeInteger(rawUserLimit)) {
|
|
73
|
+
updateData.enterpriseLicenseUserLimit = rawUserLimit as number;
|
|
74
|
+
} else {
|
|
75
|
+
warnings.push(
|
|
76
|
+
`Ignored userLimit "${String(
|
|
77
|
+
rawUserLimit,
|
|
78
|
+
)}" from the license server: it is not a non-negative whole number.`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/*
|
|
84
|
+
* The count and its timestamp move together: a count with a stamp from a
|
|
85
|
+
* previous report would read as "confirmed today", which is the exact
|
|
86
|
+
* lie this whole sync exists to stop telling.
|
|
87
|
+
*/
|
|
88
|
+
if (this.isPresent(payload, "currentUserCount")) {
|
|
89
|
+
const rawUserCount: unknown = payload["currentUserCount"];
|
|
90
|
+
|
|
91
|
+
if (this.isNonNegativeInteger(rawUserCount)) {
|
|
92
|
+
updateData.enterpriseLicenseCurrentUserCount = rawUserCount as number;
|
|
93
|
+
updateData.enterpriseLicenseUserCountUpdatedAt =
|
|
94
|
+
this.parseDate(payload["userCountUpdatedAt"]) || data.reportedAt;
|
|
95
|
+
} else if (rawUserCount === null) {
|
|
96
|
+
/*
|
|
97
|
+
* The server has never had a count for this license. Nothing useful to
|
|
98
|
+
* store, and blanking the last known count would be worse than keeping
|
|
99
|
+
* it, so leave both columns alone.
|
|
100
|
+
*/
|
|
101
|
+
warnings.push(
|
|
102
|
+
"The license server reported no user count for this license. Keeping the previously stored usage.",
|
|
103
|
+
);
|
|
104
|
+
} else {
|
|
105
|
+
warnings.push(
|
|
106
|
+
`Ignored currentUserCount "${String(
|
|
107
|
+
rawUserCount,
|
|
108
|
+
)}" from the license server: it is not a non-negative whole number. Keeping the previously stored usage.`,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/*
|
|
114
|
+
* Expiry drives licenseValid on the installation, so a renewal that never
|
|
115
|
+
* arrives here shows up as a license that expires on its original date
|
|
116
|
+
* however much the customer paid. Null is treated as absent on purpose: a
|
|
117
|
+
* report is not the place to strip an installation of its expiry.
|
|
118
|
+
*/
|
|
119
|
+
if (this.isPresent(payload, "expiresAt") && payload["expiresAt"] !== null) {
|
|
120
|
+
const expiresAt: Date | null = this.parseDate(payload["expiresAt"]);
|
|
121
|
+
|
|
122
|
+
if (expiresAt) {
|
|
123
|
+
updateData.enterpriseLicenseExpiresAt = expiresAt;
|
|
124
|
+
} else {
|
|
125
|
+
warnings.push(
|
|
126
|
+
`Ignored expiresAt "${String(
|
|
127
|
+
payload["expiresAt"],
|
|
128
|
+
)}" from the license server: it is not a date.`,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (this.isPresent(payload, "isEvaluationLicense")) {
|
|
134
|
+
const rawIsEvaluation: unknown = payload["isEvaluationLicense"];
|
|
135
|
+
|
|
136
|
+
if (typeof rawIsEvaluation === "boolean") {
|
|
137
|
+
updateData.enterpriseLicenseIsEvaluation = rawIsEvaluation;
|
|
138
|
+
} else {
|
|
139
|
+
warnings.push(
|
|
140
|
+
`Ignored isEvaluationLicense "${String(
|
|
141
|
+
rawIsEvaluation,
|
|
142
|
+
)}" from the license server: it is not a boolean.`,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (this.isPresent(payload, "companyName")) {
|
|
148
|
+
const companyName: string = this.parseNonEmptyString(
|
|
149
|
+
payload["companyName"],
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
/*
|
|
153
|
+
* An empty company name is what the server sends for a license with none
|
|
154
|
+
* set, and overwriting a good name with "" would be a visible regression
|
|
155
|
+
* in the modal. Only a real name is worth writing.
|
|
156
|
+
*/
|
|
157
|
+
if (companyName) {
|
|
158
|
+
updateData.enterpriseCompanyName = companyName;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/*
|
|
163
|
+
* The token is opaque here - it is signed with the license server's secret
|
|
164
|
+
* and this side only checks that it exists. It is never cleared: a license
|
|
165
|
+
* that has expired is legitimately issued no token, and clearing it on the
|
|
166
|
+
* way past would compound an expiry the customer may be about to renew.
|
|
167
|
+
*/
|
|
168
|
+
if (this.isPresent(payload, "token")) {
|
|
169
|
+
const token: string = this.parseNonEmptyString(payload["token"]);
|
|
170
|
+
|
|
171
|
+
if (token) {
|
|
172
|
+
updateData.enterpriseLicenseToken = token;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (this.isPresent(payload, "instances")) {
|
|
177
|
+
const rawInstances: unknown = payload["instances"];
|
|
178
|
+
|
|
179
|
+
if (Array.isArray(rawInstances)) {
|
|
180
|
+
updateData.enterpriseLicenseInstances =
|
|
181
|
+
rawInstances as Array<EnterpriseLicenseInstanceSummary>;
|
|
182
|
+
} else {
|
|
183
|
+
warnings.push(
|
|
184
|
+
"Ignored the instance list from the license server: it is not an array.",
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
updateData: updateData,
|
|
191
|
+
warnings: warnings,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/*
|
|
196
|
+
* Present means the key is on the object, even when it carries null or
|
|
197
|
+
* undefined. `"x" in payload` rather than a truthiness or undefined check,
|
|
198
|
+
* because "the server said null" and "the server said nothing" have to stay
|
|
199
|
+
* distinguishable all the way down.
|
|
200
|
+
*/
|
|
201
|
+
private static isPresent(payload: JSONObject, key: string): boolean {
|
|
202
|
+
if (!Object.prototype.hasOwnProperty.call(payload, key)) {
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// An explicit undefined is indistinguishable from silence. Treat it as one.
|
|
207
|
+
return payload[key] !== undefined;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
private static isNonNegativeInteger(value: unknown): boolean {
|
|
211
|
+
return (
|
|
212
|
+
typeof value === "number" &&
|
|
213
|
+
Number.isFinite(value) &&
|
|
214
|
+
Number.isInteger(value) &&
|
|
215
|
+
value >= 0
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
private static parseNonEmptyString(value: unknown): string {
|
|
220
|
+
if (typeof value !== "string") {
|
|
221
|
+
return "";
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return value.trim();
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
private static parseDate(value: unknown): Date | null {
|
|
228
|
+
if (value instanceof Date) {
|
|
229
|
+
return Number.isNaN(value.getTime()) ? null : value;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const parsed: Date = new Date(value.trim());
|
|
237
|
+
|
|
238
|
+
if (Number.isNaN(parsed.getTime())) {
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return parsed;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
@@ -83,18 +83,13 @@ export default class EnterpriseLicenseAPI extends BaseAPI {
|
|
|
83
83
|
});
|
|
84
84
|
}
|
|
85
85
|
const instances = await this.findLicenseInstances(license.id);
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
expiresAt: license.expiresAt.toISOString(),
|
|
89
|
-
licenseKey: license.licenseKey || "",
|
|
90
|
-
userLimit: typeof license.userLimit === "number" ? license.userLimit : null,
|
|
91
|
-
};
|
|
92
|
-
const token = JSONWebToken.signJsonPayload(payload, Math.max(secondsUntilExpiry, 1));
|
|
86
|
+
const terms = this.getLicenseTerms(license);
|
|
87
|
+
const token = this.signLicenseToken(license);
|
|
93
88
|
return Response.sendJsonObjectResponse(req, res, {
|
|
94
|
-
companyName:
|
|
95
|
-
expiresAt:
|
|
96
|
-
licenseKey:
|
|
97
|
-
userLimit:
|
|
89
|
+
companyName: terms.companyName,
|
|
90
|
+
expiresAt: terms.expiresAt,
|
|
91
|
+
licenseKey: terms.licenseKey,
|
|
92
|
+
userLimit: terms.userLimit,
|
|
98
93
|
currentUserCount: typeof license.currentUserCount === "number"
|
|
99
94
|
? license.currentUserCount
|
|
100
95
|
: null,
|
|
@@ -128,8 +123,19 @@ export default class EnterpriseLicenseAPI extends BaseAPI {
|
|
|
128
123
|
query: {
|
|
129
124
|
licenseKey: licenseKey,
|
|
130
125
|
},
|
|
126
|
+
/*
|
|
127
|
+
* The full set of terms, not just the usage columns: this
|
|
128
|
+
* response is what a self-hosted installation mirrors into its
|
|
129
|
+
* own GlobalConfig every day, so anything it is missing here is
|
|
130
|
+
* a field the customer keeps the stale value of until somebody
|
|
131
|
+
* re-types the license key by hand. Same indexed lookup either
|
|
132
|
+
* way.
|
|
133
|
+
*/
|
|
131
134
|
select: {
|
|
132
135
|
_id: true,
|
|
136
|
+
companyName: true,
|
|
137
|
+
expiresAt: true,
|
|
138
|
+
licenseKey: true,
|
|
133
139
|
userLimit: true,
|
|
134
140
|
isEvaluationLicense: true,
|
|
135
141
|
},
|
|
@@ -185,12 +191,24 @@ export default class EnterpriseLicenseAPI extends BaseAPI {
|
|
|
185
191
|
},
|
|
186
192
|
});
|
|
187
193
|
}
|
|
194
|
+
const terms = this.getLicenseTerms(license);
|
|
188
195
|
return Response.sendJsonObjectResponse(req, res, {
|
|
196
|
+
companyName: terms.companyName,
|
|
197
|
+
expiresAt: terms.expiresAt,
|
|
198
|
+
licenseKey: terms.licenseKey,
|
|
199
|
+
userLimit: terms.userLimit,
|
|
189
200
|
currentUserCount: currentUserCount,
|
|
190
201
|
userCountUpdatedAt: reportedAt.toISOString(),
|
|
191
|
-
userLimit: typeof license.userLimit === "number" ? license.userLimit : null,
|
|
192
202
|
isEvaluationLicense: Boolean(license.isEvaluationLicense),
|
|
193
203
|
instances: this.getInstanceSummaries(instances),
|
|
204
|
+
/*
|
|
205
|
+
* Null once the license has expired. Unlike /validate this route
|
|
206
|
+
* does not reject an expired license - the instance is still
|
|
207
|
+
* expected to report, and the expiry notification emails are built
|
|
208
|
+
* from what it reports - but it must not keep being handed a fresh
|
|
209
|
+
* token, and the expiresAt above is what tells it the truth.
|
|
210
|
+
*/
|
|
211
|
+
token: this.signLicenseToken(license),
|
|
194
212
|
});
|
|
195
213
|
}
|
|
196
214
|
catch (err) {
|
|
@@ -198,6 +216,42 @@ export default class EnterpriseLicenseAPI extends BaseAPI {
|
|
|
198
216
|
}
|
|
199
217
|
});
|
|
200
218
|
}
|
|
219
|
+
/*
|
|
220
|
+
* The license terms a self-hosted installation mirrors locally. Built in one
|
|
221
|
+
* place so /validate and /report-user-count cannot drift: the seat limit was
|
|
222
|
+
* missing from what an instance could refresh for exactly as long as the two
|
|
223
|
+
* responses were written out by hand, separately.
|
|
224
|
+
*/
|
|
225
|
+
getLicenseTerms(license) {
|
|
226
|
+
return {
|
|
227
|
+
companyName: license.companyName || "",
|
|
228
|
+
expiresAt: license.expiresAt ? license.expiresAt.toISOString() : null,
|
|
229
|
+
licenseKey: license.licenseKey || "",
|
|
230
|
+
userLimit: typeof license.userLimit === "number" ? license.userLimit : null,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
/*
|
|
234
|
+
* Signed with the license server's own secret, so it is opaque to the
|
|
235
|
+
* installation holding it - it is the proof that oneuptime.com recognized
|
|
236
|
+
* the key, and the instance only checks that it exists. An expired license
|
|
237
|
+
* gets none, which is why this returns null rather than a short-lived token.
|
|
238
|
+
*/
|
|
239
|
+
signLicenseToken(license) {
|
|
240
|
+
if (!license.expiresAt) {
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
const secondsUntilExpiry = Math.floor((license.expiresAt.getTime() - Date.now()) / 1000);
|
|
244
|
+
if (secondsUntilExpiry <= 0) {
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
const terms = this.getLicenseTerms(license);
|
|
248
|
+
return JSONWebToken.signJsonPayload({
|
|
249
|
+
companyName: terms.companyName,
|
|
250
|
+
expiresAt: terms.expiresAt,
|
|
251
|
+
licenseKey: terms.licenseKey,
|
|
252
|
+
userLimit: terms.userLimit,
|
|
253
|
+
}, Math.max(secondsUntilExpiry, 1));
|
|
254
|
+
}
|
|
201
255
|
parseShortText(value) {
|
|
202
256
|
if (typeof value !== "string") {
|
|
203
257
|
return "";
|